@1agh/maude 1.4.6 → 1.5.1

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 (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -18,6 +18,7 @@ import path from 'node:path';
18
18
  import { createAcp } from './acp/index.ts';
19
19
  import { cancelInstall, cancelSignin } from './acp/login-state.ts';
20
20
  import { createActivity } from './activity.ts';
21
+ import { migrateAnnotationsV2 } from './annotations/migrate-boot.ts';
21
22
  import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
22
23
  import { bootSelfHeal } from './boot-self-heal.ts';
23
24
  import { isSandboxArmed } from './canvas-build-sandbox.ts';
@@ -129,6 +130,13 @@ const api = createApi(ctx, {
129
130
  collab.registry.syncRoomFromAnnotations(api.fileSlug(file), svg, writeId);
130
131
  }
131
132
  },
133
+ // Code review H1 — the canvas op path goes to a live room's replica, not to a
134
+ // file its debounced flush hasn't caught up with. Accepted mode keeps the
135
+ // disk + proposeLane path: there the hub kernel merges against the base.
136
+ applyAnnotationOpsLive: (file, ops, actionId) => {
137
+ if (ctx.syncControl?.current?.()?.acceptedMode?.()) return null;
138
+ return collab?.registry.applyOpsToRoom(api.fileSlug(file), ops, actionId) ?? null;
139
+ },
132
140
  // feature-file-tree-drag-drop-folders (Task 3) — moveCanvas's collab guard
133
141
  // + `_active.json` retarget, bridged the same forward-declared way as the
134
142
  // comments/annotations hooks above.
@@ -182,6 +190,13 @@ ctx.bus.on('canvas-list-update', (change) => {
182
190
  }
183
191
  });
184
192
 
193
+ // DDR-242 — convert legacy `<slug>.annotations.svg` boards to
194
+ // `.annotations.json` BEFORE any room seeds or sync scan reads them. Skipped in
195
+ // a cloud workspace: the hub owns that checkout (its workspace agent migrates).
196
+ if (process.env.MAUDE_WORKSPACE_MODE !== '1') {
197
+ migrateAnnotationsV2({ designRoot: ctx.paths.designRoot });
198
+ }
199
+
185
200
  collab = createCollab(ctx, api);
186
201
  const aiActivity = createAiActivity(ctx);
187
202
 
@@ -518,6 +533,7 @@ function startCanvasServer(port: number): BunServer {
518
533
  '/_api/git-user': http.routes['/_api/git-user'],
519
534
  '/_api/canvas-meta': http.routes['/_api/canvas-meta'],
520
535
  '/_api/annotations': http.routes['/_api/annotations'],
536
+ '/_api/annotations/ops': http.routes['/_api/annotations/ops'],
521
537
  // Phase 23 — capped binary image upload (magic-byte sniff + category cap +
522
538
  // content-addressed name + traversal guard + no-SVG, in api.saveAsset).
523
539
  // Bun matches `routes` BEFORE `fetch`, so the route must be listed here
@@ -40,7 +40,14 @@
40
40
  import { existsSync, readFileSync } from 'node:fs';
41
41
  import type * as Y from 'yjs';
42
42
 
43
- import { cssFromDoc, htmlFromDoc, laneValueFromFile, readLaneFromDoc } from './codec.ts';
43
+ import {
44
+ cssFromDoc,
45
+ htmlFromDoc,
46
+ isEmptyAnnotationsSvg,
47
+ laneValueFromFile,
48
+ readLaneFromDoc,
49
+ readLocalAnnotations,
50
+ } from './codec.ts';
44
51
  import { commentKey } from './comment-identity.ts';
45
52
  import type { CommentLedger } from './comment-ledger.ts';
46
53
  import { hashBytes } from './echo-guard.ts';
@@ -180,8 +187,9 @@ export async function acceptedColdStart(
180
187
  const metaText = readText(i.paths.meta);
181
188
  const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
182
189
  if (meta && meta !== '{}') lanes.meta = meta;
183
- const ann = readText(i.paths.annotations);
184
- if (ann) lanes.annotations = ann;
190
+ // DDR-242 — canonical board text; a legacy sidecar arrives migrated.
191
+ const ann = readLocalAnnotations(i.paths.annotations, readText);
192
+ if (ann && !isEmptyAnnotationsSvg(ann)) lanes.annotations = ann;
185
193
  const commentsText = readText(i.paths.comments);
186
194
  const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
187
195
  if (comments) lanes.comments = comments;
@@ -267,7 +275,7 @@ export async function acceptedColdStart(
267
275
  // ---- comments / annotations — the accepted value wins (see header)
268
276
  for (const lane of ['comments', 'annotations'] as const) {
269
277
  const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
270
- const text = readText(p);
278
+ const text = lane === 'annotations' ? readLocalAnnotations(p, readText) : readText(p);
271
279
  const local = text === null ? null : laneValueFromFile(lane, text);
272
280
  const accepted = readLaneFromDoc(i.doc, lane);
273
281
  if (local === null || local === accepted) {
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // `.design/<slug>.html` ←→ Y.Text (Y_SYNC_TYPES.html)
7
7
  // `.design/_comments/<slug>.json` ←→ Y.Array (Y_TYPES.comments)
8
- // `.design/<slug>.annotations.svg`←→ Y.Map.svg (Y_TYPES.annotations)
8
+ // `.design/<slug>.annotations.json`←→ 'annotations2' replica (DDR-242)
9
9
  //
10
10
  // Provider is INJECTED — the agent doesn't import @hocuspocus/provider. This
11
11
  // keeps the orchestration testable with an in-memory pair of Y.Docs (no hub
@@ -51,10 +51,13 @@ import {
51
51
  commentsFromDoc,
52
52
  cssFromDoc,
53
53
  htmlFromDoc,
54
+ importAnnotationsFromDisk,
54
55
  isEmptyAnnotationsSvg,
55
56
  mergeSharedMetaIntoLocal,
56
57
  metaFromDoc,
57
58
  movedToFromDoc,
59
+ noteAnnotationsOnDisk,
60
+ readLocalAnnotations,
58
61
  repairSharedMeta,
59
62
  stampAnnotationsEdit,
60
63
  stampBodyEdit,
@@ -88,7 +91,7 @@ export interface CanvasSyncPaths {
88
91
  html: string;
89
92
  /** Absolute path to <designRoot>/_comments/<slug>.json. */
90
93
  comments: string;
91
- /** Absolute path to <designRoot>/<slug>.annotations.svg. */
94
+ /** Absolute path to <designRoot>/<slug>.annotations.json (DDR-242). */
92
95
  annotations: string;
93
96
  /** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
94
97
  * when set (always, in production wiring), shared meta keys (layout/artboards)
@@ -368,6 +371,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
368
371
  }
369
372
  const hash = hashBytes(value);
370
373
  echoGuard.record(paths.annotations, hash);
374
+ noteAnnotationsOnDisk(doc, value);
371
375
  writer(paths.annotations, value);
372
376
  lastAnnotations = value;
373
377
  }
@@ -438,7 +442,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
438
442
  // wrapper written by saveAnnotations) cold-start-safe on other peers.
439
443
  let changed = false;
440
444
  doc.transact(() => {
441
- changed = applyAnnotationsToDoc(doc, str, origin);
445
+ changed = importAnnotationsFromDisk(doc, str, origin, lastAnnotations);
442
446
  if (changed) stampAnnotationsEdit(doc, origin);
443
447
  }, origin);
444
448
  if (changed) lastAnnotations = str;
@@ -470,7 +474,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
470
474
  if (movedToFromDoc(doc) !== null) return;
471
475
  const localHtml = readLocal(paths.html);
472
476
  const localComments = readLocal(paths.comments);
473
- const localAnnotations = readLocal(paths.annotations);
477
+ const localAnnotations = readLocalAnnotations(paths.annotations, readLocal);
474
478
  const localMeta = paths.meta ? readLocal(paths.meta) : null;
475
479
  const localCss = paths.css ? readLocal(paths.css) : null;
476
480
 
@@ -1,4 +1,3 @@
1
- import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
2
1
  // Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
3
2
  //
4
3
  // The agent shuttles three classes of files between disk and the Y.Doc the
@@ -6,7 +5,8 @@ import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
6
5
  //
7
6
  // `.design/<canvas>.html` -> Y.Text (Y_SYNC_TYPES.html)
8
7
  // `.design/_comments/<slug>.json` -> Y.Array (Y_TYPES.comments — Phase 6)
9
- // `.design/<slug>.annotations.svg` -> Y.Map.svg (Y_TYPES.annotations — Phase 5)
8
+ // `.design/<slug>.annotations.json` -> Y.Map ('annotations2' — DDR-242; per-element replica,
9
+ // see annotations/replica.ts)
10
10
  //
11
11
  // v1.1 design decision (plan §"Key insight"): HTML body is treated as opaque
12
12
  // Y.Text rather than structured Y.XmlFragment. Round-trip drift would
@@ -26,6 +26,18 @@ import { hostname } from 'node:os';
26
26
  import { diffChars } from 'diff';
27
27
  import type * as Y from 'yjs';
28
28
 
29
+ import { annotationsLaneValue, canonicalAnnotations } from '../annotations/board-text.ts';
30
+ import { applyOps, diffToOps } from '../annotations/ops.ts';
31
+ import '../annotations/ops-merge.ts';
32
+ import {
33
+ annotationsOnDiskOf,
34
+ isEmptyBoardText,
35
+ noteAnnotationsOnDisk,
36
+ readReplica,
37
+ replicaBoardText,
38
+ writeReplica,
39
+ } from '../annotations/replica.ts';
40
+ import { parseBoard, serializeBoard } from '../annotations/schema.ts';
29
41
  import { Y_TYPES } from '../collab/persistence.ts';
30
42
  import { commentKey, dedupeCommentsById } from './comment-identity.ts';
31
43
  import {
@@ -293,30 +305,84 @@ export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?:
293
305
 
294
306
  /* ---------------------------------------------------------------- annotations */
295
307
 
296
- /** Returns the annotations SVG string, or null if unset. */
308
+ export { canonicalAnnotations };
309
+
310
+ /**
311
+ * The local board (canonical text) at `jsonPath`, falling back to a not-yet-
312
+ * migrated legacy `.annotations.svg` sibling — so a cold start that races the
313
+ * boot migration still sees the local strokes instead of "no local" (which lets
314
+ * the hub win by default: the DDR-223 eraser shape). null = neither exists.
315
+ */
316
+ export function readLocalAnnotations(
317
+ jsonPath: string,
318
+ read: (abs: string) => string | null
319
+ ): string | null {
320
+ const local = read(jsonPath);
321
+ if (local !== null) return canonicalAnnotations(local) ?? local;
322
+ const legacy = read(jsonPath.replace(/\.json$/, '.svg'));
323
+ return legacy === null ? null : canonicalAnnotations(legacy);
324
+ }
325
+
326
+ /** The board held by the doc as canonical text, or null if never populated. */
297
327
  export function annotationsFromDoc(doc: Y.Doc): string | null {
298
- const map = doc.getMap<unknown>(Y_TYPES.annotations);
299
- const svg = map.get('svg');
300
- return typeof svg === 'string' ? svg : null;
328
+ return replicaBoardText(doc);
301
329
  }
302
330
 
303
331
  /**
304
- * True when an annotations value carries ZERO strokes: null, `''`, or the bare
305
- * serialized wrapper `<svg …></svg>` with no child elements (what
306
- * `strokesToSvg([])` emits — 72 bytes, constant across peers).
332
+ * True when an annotations value carries ZERO elements: null, `''`, an empty
333
+ * board, or the bare legacy wrapper `<svg …></svg>`.
307
334
  *
308
- * This distinction is load-bearing for cold start (the 2026-08-14 annotations
309
- * eraser): the wrapper is a non-empty STRING, so every `!== ''` emptiness
310
- * guard let a stale hub wrapper overwrite a peer's real strokes — and with the
311
- * strokes went the `assets/<sha8>` references the asset lane pulled by, so
312
- * freshly dropped images never crossed machines. Live delete-all still materializes
313
- * the wrapper through `writeAnnotationsIfChanged` (deletes must propagate);
314
- * only COLD-START decisions treat it as emptiness.
335
+ * Load-bearing for cold start (the 2026-08-14 annotations eraser, DDR-223): the
336
+ * legacy wrapper was a non-empty STRING, so every `!== ''` guard let a stale hub
337
+ * wrapper overwrite a peer's real strokes. Emptiness is "zero elements", never a
338
+ * byte heuristic. Live delete-all still materializes; only COLD-START decisions
339
+ * treat this as emptiness.
340
+ */
341
+ export function isEmptyAnnotationsSvg(text: string | null): boolean {
342
+ return isEmptyBoardText(text);
343
+ }
344
+
345
+ /**
346
+ * Make the doc's replica hold `next` (board text or legacy SVG), writing only
347
+ * the elements / fields that differ. `null` / `''` → an empty board. A
348
+ * filesystem import is a new operation, never the previous UI author's echo:
349
+ * `writeReplica` clears '~action' on any change made without an action id.
350
+ */
351
+ /**
352
+ * A disk change to the board, imported into the doc as the CHANGE it made —
353
+ * the ops from what disk last held to what it holds now — never as a
354
+ * replacement. A projection written a moment before a newer edit, whose file
355
+ * event arrives after it, would otherwise put the older values back (the
356
+ * multiplayer rig's lost toolbar edits and deletes). Without a known base
357
+ * (first sight of the file) it is a full apply. Returns whether the doc changed.
315
358
  */
316
- export function isEmptyAnnotationsSvg(svg: string | null): boolean {
317
- if (svg === null) return true;
318
- if (svg.trim() === '') return true;
319
- return /^\s*<svg\b[^>]*>\s*<\/svg>\s*$/i.test(svg);
359
+ export { noteAnnotationsOnDisk };
360
+
361
+ export function importAnnotationsFromDisk(
362
+ doc: Y.Doc,
363
+ next: string | null,
364
+ origin?: unknown,
365
+ fallbackBase?: string | null
366
+ ): boolean {
367
+ const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
368
+ if (text === null || byteLengthUtf8(text) > MAX_ANNOTATIONS_BYTES) {
369
+ return applyAnnotationsToDoc(doc, next, origin);
370
+ }
371
+ const known = annotationsOnDiskOf(doc);
372
+ const base =
373
+ (known !== undefined ? canonicalAnnotations(known) : null) ??
374
+ (fallbackBase ? canonicalAnnotations(fallbackBase) : null);
375
+ const cur = readReplica(doc);
376
+ noteAnnotationsOnDisk(doc, text);
377
+ if (base === null || cur === null) {
378
+ return applyAnnotationsToDoc(doc, text, origin);
379
+ }
380
+ const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
381
+ const ops = diffToOps(toMap(base), toMap(text));
382
+ if (!ops.length) return false;
383
+ const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
384
+ if (!r.touched.size) return false;
385
+ return writeReplica(doc, [...r.state.values()], origin);
320
386
  }
321
387
 
322
388
  export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
@@ -326,21 +392,13 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
326
392
  );
327
393
  return false;
328
394
  }
329
- const map = doc.getMap<unknown>(Y_TYPES.annotations);
330
- const current = map.get('svg');
331
- const currentStr = typeof current === 'string' ? current : null;
332
- if (currentStr === next) return false;
333
-
334
- doc.transact(() => {
335
- // A filesystem import is a new operation, never the previous UI author's echo.
336
- map.delete(ANNOTATION_WRITE_ID);
337
- if (next === null || next === '') {
338
- map.delete('svg');
339
- } else {
340
- map.set('svg', next);
341
- }
342
- }, origin);
343
- return true;
395
+ const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
396
+ if (text === null) {
397
+ console.warn('[sync/codec] refusing annotations apply: not a board document');
398
+ return false;
399
+ }
400
+ if (readReplica(doc) !== null && replicaBoardText(doc) === text) return false;
401
+ return writeReplica(doc, parseBoard(text).elements, origin);
344
402
  }
345
403
 
346
404
  /* ---------------------------------------------------------------- meta */
@@ -426,7 +484,8 @@ export function laneValueFromFile(
426
484
  lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
427
485
  text: string
428
486
  ): string | null {
429
- if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
487
+ if (lane === 'html' || lane === 'css') return text;
488
+ if (lane === 'annotations') return annotationsLaneValue(text);
430
489
  let parsed: unknown;
431
490
  try {
432
491
  parsed = parseJsonSafe(text);
@@ -448,10 +507,7 @@ export function readLaneFromDoc(
448
507
  lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
449
508
  ): string {
450
509
  if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
451
- if (lane === 'annotations') {
452
- const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
453
- return typeof svg === 'string' ? svg : '';
454
- }
510
+ if (lane === 'annotations') return annotationsLaneValue(replicaBoardText(doc) ?? '') ?? '';
455
511
  const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
456
512
  return list.length ? JSON.stringify(list) : '';
457
513
  }
@@ -199,6 +199,16 @@ export interface ClassifyOptions {
199
199
  * `node_modules`, directory segments start alphanumeric, the final segment
200
200
  * may start with `_`.
201
201
  */
202
+ /**
203
+ * The annotations board (DDR-242: `<slug>.annotations.json`) and its legacy v1
204
+ * form (`<slug>.annotations.svg`). BOTH stay canvas-owned: the board is the doc
205
+ * lane's, and a stale legacy sidecar must never ride the file plane between
206
+ * machines (the boot migration quarantines it locally).
207
+ */
208
+ function isAnnotationsSidecar(lowerName: string): boolean {
209
+ return lowerName.endsWith('.annotations.json') || lowerName.endsWith('.annotations.svg');
210
+ }
211
+
202
212
  export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): FileClass {
203
213
  const parts = relShape(rel);
204
214
  if (parts === null) return 'never';
@@ -219,7 +229,7 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
219
229
  // The canvas body and its NAMED sidecars — Plane A's, by construction.
220
230
  if (lowerLast.endsWith('.tsx')) return 'canvas-owned';
221
231
  if (lowerLast.endsWith('.meta.json')) return 'canvas-owned';
222
- if (lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
232
+ if (isAnnotationsSidecar(lowerLast)) return 'canvas-owned';
223
233
  if (lowerLast.endsWith('.css') && opts.hasFile) {
224
234
  const sibling = `${rel.slice(0, -'.css'.length)}.tsx`;
225
235
  if (opts.hasFile(sibling)) return 'canvas-owned';
@@ -238,7 +248,7 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
238
248
  // pushed over them at 10:50:33). The annotations lane's own stamped
239
249
  // newest-wins protection never saw it coming — it guards the DOC lane, and
240
250
  // this was the file plane acting alone. One owner: the canvas.
241
- if (parts.length === 1 && lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
251
+ if (parts.length === 1 && isAnnotationsSidecar(lowerLast)) return 'canvas-owned';
242
252
 
243
253
  if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
244
254
 
@@ -2397,7 +2397,7 @@ function holdOversized(out: FilePlaneResult, ledger: FileLedger, file: Oversized
2397
2397
  function referencedAssetNames(designRoot: string): Set<string> {
2398
2398
  const out = new Set<string>();
2399
2399
  const RE = /assets\/([A-Za-z0-9._-]+\.[A-Za-z0-9]+)/g;
2400
- const SCANNED = /\.(?:annotations\.svg|tsx|jsx|css|meta\.json)$/i;
2400
+ const SCANNED = /\.(?:annotations\.(?:svg|json)|tsx|jsx|css|meta\.json)$/i;
2401
2401
  const walk = (dir: string, depth: number): void => {
2402
2402
  if (depth > MAX_WALK_DEPTH) return;
2403
2403
  let entries: Dirent[];
@@ -33,6 +33,8 @@ import { hostname } from 'node:os';
33
33
  import path from 'node:path';
34
34
  import type { Awareness } from 'y-protocols/awareness';
35
35
  import * as Y from 'yjs';
36
+ import { migrateAnnotationsV2 } from '../annotations/migrate-boot.ts';
37
+ import { REPLICA_TYPE, replicaBoardText } from '../annotations/replica.ts';
36
38
  import { renewHubCredential } from '../cloud/renew.ts';
37
39
  import { Y_TYPES } from '../collab/persistence.ts';
38
40
  import type { Context, LinkedHub } from '../context.ts';
@@ -71,13 +73,14 @@ import { createFsReader, type FsReader } from './fs-mirror.ts';
71
73
  import { type HubDocRow, hubHolds, indexHubDocs } from './hub-listing.ts';
72
74
  import { getHubRecord } from './hubs-config.ts';
73
75
  import { loadJournal, type SyncJournal } from './journal.ts';
74
- import { hasLedger, hubCapabilities } from './journal-client.ts';
76
+ import { hasAnnotationsV2, hasLedger, hubCapabilities } from './journal-client.ts';
75
77
  import { isLoopbackHost } from './loopback.ts';
76
78
  import { migrateFlatFallback } from './migrate-flat-fallback.ts';
77
79
  import { migrateSeed } from './migrate-seed.ts';
78
80
  import { ORIGINS } from './origins.ts';
79
81
  import { createDocProjection, type DocProjection } from './projection.ts';
80
82
  import {
83
+ acceptedListing,
81
84
  describeRemoteDiff,
82
85
  diffRemoteDocs,
83
86
  fetchRemoteListing,
@@ -2070,12 +2073,10 @@ export function createSyncRuntime(
2070
2073
  // connect before doc.create commits; pulling that empty room would lock
2071
2074
  // the receiver onto a lossy slug-derived path before the real path arrives.
2072
2075
  // The accepted manifest alone names live canvases, including successors
2073
- // of retired documents whose transport rows may still linger.
2074
- const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
2075
- const documents = manifest.docs
2076
- .filter((d) => !d.retired)
2077
- .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
2078
- return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
2076
+ // of retired documents whose transport rows may still linger — and a
2077
+ // legacy tombstone for a name the manifest lists live no longer buries it
2078
+ // (see `acceptedListing`).
2079
+ return { ...(listing ?? {}), ...acceptedListing(listing, manifest.docs) };
2079
2080
  }
2080
2081
 
2081
2082
  /**
@@ -2242,6 +2243,9 @@ export function createSyncRuntime(
2242
2243
  designRoot: ctx.paths.designRoot,
2243
2244
  designRel: ctx.paths.designRel,
2244
2245
  });
2246
+ // DDR-242 — idempotent; also covers a runtime cycled after boot (a
2247
+ // project linked from the cloud panel) whose tree gained a v1 board.
2248
+ migrateAnnotationsV2({ designRoot: ctx.paths.designRoot });
2245
2249
  }
2246
2250
 
2247
2251
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
@@ -2382,18 +2386,23 @@ export function createSyncRuntime(
2382
2386
  path.sep,
2383
2387
  { ...pathOpts, realpath: realpathOfDeepestExisting, pathFor: manifestPathFor }
2384
2388
  );
2385
- const pullNote = describeRemoteDiff(remoteDiff);
2389
+ // Named as it will actually happen: a canvas the project deleted is listed
2390
+ // for a tick after its tombstone and is not pulled, so it is not announced.
2391
+ const pullNote = describeRemoteDiff({
2392
+ ...remoteDiff,
2393
+ hubOnly: remoteDiff.hubOnly.filter((d) => !tombstoned.has(slugFromDocName(d.name) ?? '')),
2394
+ });
2386
2395
  if (pullNote) console.log(`[sync] ${pullNote}`);
2387
2396
  /** Descriptor paths for one slug at one body path. The sidecar rules live
2388
2397
  * here, once: `.meta.json`/`.css` are SIBLINGS of the body, while
2389
- * `.annotations.svg` is keyed by the flat slug at the design root — the
2398
+ * `.annotations.json` is keyed by the flat slug at the design root — the
2390
2399
  * asymmetry `workspace-files.mjs` documents, and which moving the body
2391
2400
  * must not quietly change. */
2392
2401
  const descriptorFor = (slug: string, bodyAbs: string): CanvasDescriptor => ({
2393
2402
  slug,
2394
2403
  html: bodyAbs,
2395
2404
  comments: path.join(ctx.paths.commentsDir, `${slug}.json`),
2396
- annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.svg`),
2405
+ annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.json`),
2397
2406
  meta: bodyAbs.replace(/\.tsx$/i, '.meta.json'),
2398
2407
  css: bodyAbs.replace(/\.tsx$/i, '.css'),
2399
2408
  });
@@ -3892,21 +3901,23 @@ export function createSyncRuntime(
3892
3901
  const agentOrigin = agent.origin;
3893
3902
  const slug = canvas.slug;
3894
3903
  const provComments = provider.document.getArray(Y_TYPES.comments);
3895
- const provAnn = provider.document.getMap(Y_TYPES.annotations);
3904
+ // DDR-242 — the annotations replica: relay the board, the room
3905
+ // diffs it per element (writeReplica), so only changes cross.
3906
+ const provAnn = provider.document.getMap(REPLICA_TYPE);
3896
3907
  const onComments = (_e: unknown, tx: { origin: unknown }) => {
3897
3908
  if (tx.origin === agentOrigin) return;
3898
3909
  reg.syncRoomFromComments?.(slug, provComments.toArray());
3899
3910
  };
3900
3911
  const onAnn = (_e: unknown, tx: { origin: unknown }) => {
3901
3912
  if (tx.origin === agentOrigin) return;
3902
- const svg = provAnn.get('svg');
3903
- if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
3913
+ const board = replicaBoardText(provider.document);
3914
+ if (board !== null) reg.syncRoomFromAnnotations?.(slug, board);
3904
3915
  };
3905
3916
  provComments.observe(onComments);
3906
- provAnn.observe(onAnn);
3917
+ provAnn.observeDeep(onAnn);
3907
3918
  noteDetach(statusDetaches, canvas.slug, () => {
3908
3919
  provComments.unobserve(onComments);
3909
- provAnn.unobserve(onAnn);
3920
+ provAnn.unobserveDeep(onAnn);
3910
3921
  });
3911
3922
  }
3912
3923
  },
@@ -4646,6 +4657,17 @@ export function createSyncRuntime(
4646
4657
  void hubCapabilities({ hubUrl: linkedHub.url, signal: fileEventsProbe.signal })
4647
4658
  .then((caps) => {
4648
4659
  if (stopped) return;
4660
+ // DDR-242 — a hub that predates the annotations-v2 model still keeps
4661
+ // boards as SVG: its workspace checkout and kernel would not carry
4662
+ // this studio's `.annotations.json` edits. Say so loudly; the
4663
+ // annotations themselves stay safe (a v1 hub never writes the v2
4664
+ // replica, and this studio never reads its SVG as authoritative).
4665
+ if (caps !== null && !hasAnnotationsV2(caps)) {
4666
+ console.warn(
4667
+ `[sync] ${linkedHub.url} does not advertise annotations-v2 — update the hub; ` +
4668
+ 'whiteboard edits will not reach its checkout until it is'
4669
+ );
4670
+ }
4649
4671
  if (!hasLedger(caps)) {
4650
4672
  // No journal on this hub ⇒ the legacy client carries the upward
4651
4673
  // lane, exactly as the pre-v2 desktop did (Open decision 4).
@@ -5355,7 +5377,7 @@ async function walk(
5355
5377
  slug,
5356
5378
  html: abs,
5357
5379
  comments: path.join(commentsDir, `${slug}.json`),
5358
- annotations: path.join(designRoot, `${slug}.annotations.svg`),
5380
+ annotations: path.join(designRoot, `${slug}.annotations.json`),
5359
5381
  // The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
5360
5382
  meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
5361
5383
  // The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
@@ -212,6 +212,11 @@ export async function hubCapabilities(opts: {
212
212
  }
213
213
  }
214
214
 
215
+ /** Does this hub keep annotations as the v2 element model (DDR-242)? */
216
+ export function hasAnnotationsV2(capabilities: string[] | null): boolean {
217
+ return Array.isArray(capabilities) && capabilities.includes('annotations-v2');
218
+ }
219
+
215
220
  /** Does this hub carry the journal file plane? */
216
221
  export function hasLedger(capabilities: string[] | null): boolean {
217
222
  return Array.isArray(capabilities) && capabilities.includes('ledger');
@@ -15,8 +15,13 @@
15
15
  export const MAX_HTML_BYTES = 4 * 1024 * 1024;
16
16
  /** `_comments/<slug>.json`, serialized. */
17
17
  export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
18
- /** `<slug>.annotations.svg`. */
19
- export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
18
+ /**
19
+ * `<slug>.annotations.json` (DDR-242). Must equal `MAX_BOARD_BYTES` in
20
+ * annotations/constants.ts — annotations-v2-replica.test.ts pins the pair.
21
+ * Raised from v1's 1 MB: the v1 cap bit at ~3.7k elements, and the v2 board is
22
+ * ~3× more compact, so 4 MB is ≈ 20k typical elements (the element-count cap).
23
+ */
24
+ export const MAX_ANNOTATIONS_BYTES = 4 * 1024 * 1024;
20
25
  /** The shared subset of a canvas `.meta.json`. */
21
26
  export const MAX_META_BYTES = 1 * 1024 * 1024;
22
27
  /** The canvas's sibling stylesheet. */
@@ -34,10 +34,12 @@ import path from 'node:path';
34
34
 
35
35
  import type * as Y from 'yjs';
36
36
 
37
+ import { readReplica } from '../annotations/replica.ts';
37
38
  import { Y_TYPES } from '../collab/persistence.ts';
38
39
  import { atomicWrite } from './atomic-write.ts';
39
40
  import {
40
41
  annotationsEditAtFromDoc,
42
+ annotationsFromDoc,
41
43
  applyAnnotationsToDoc,
42
44
  applyCommentsToDoc,
43
45
  applyCssToDoc,
@@ -45,6 +47,7 @@ import {
45
47
  applyMetaToDoc,
46
48
  bodyEditAtFromDoc,
47
49
  isEmptyAnnotationsSvg,
50
+ readLocalAnnotations,
48
51
  stampAnnotationsEdit,
49
52
  stampBodyEdit,
50
53
  Y_SYNC_TYPES,
@@ -155,8 +158,9 @@ export function docIsEmpty(doc: Y.Doc): boolean {
155
158
  if (doc.getText(Y_SYNC_TYPES.css).length > 0) return false;
156
159
  if (doc.getText(Y_SYNC_TYPES.meta).length > 0) return false;
157
160
  if (doc.getArray(Y_TYPES.comments).length > 0) return false;
158
- const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
159
- if (typeof svg === 'string' && svg.length > 0) return false;
161
+ // DDR-242 — the annotations replica (or a legacy v1 value read through the
162
+ // migration): content means at least one element.
163
+ if ((readReplica(doc)?.elements.length ?? 0) > 0) return false;
160
164
  return true;
161
165
  }
162
166
 
@@ -174,7 +178,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
174
178
  const localHtml =
175
179
  diskHtml !== null && sourceError(paths.html, diskHtml) === null ? diskHtml : null;
176
180
  const localComments = readLocal(paths.comments);
177
- const localAnnotations = readLocal(paths.annotations);
181
+ const localAnnotations = readLocalAnnotations(paths.annotations, readLocal);
178
182
  const localMeta = paths.meta ? readLocal(paths.meta) : null;
179
183
  const readCss = paths.css ? readLocal(paths.css) : null;
180
184
  const localCss = readCss === null ? null : (collapseRepeatedText(readCss)?.unit ?? readCss);
@@ -355,8 +359,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
355
359
  // right after this seed ran. Resolve the lane here, before the room
356
360
  // materializes: unstamped emptiness never beats content.
357
361
  {
358
- const docSvg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
359
- const docAnnotations = typeof docSvg === 'string' ? docSvg : '';
362
+ const docAnnotations = annotationsFromDoc(doc) ?? '';
360
363
  const annDecision = decideAnnotationsColdStart({
361
364
  local: localAnnotations,
362
365
  doc: docAnnotations,
@@ -37,10 +37,12 @@ import {
37
37
  applyMetaToDoc,
38
38
  cssFromDoc,
39
39
  htmlFromDoc,
40
+ importAnnotationsFromDisk,
40
41
  laneValueFromFile,
41
42
  mergeSharedMetaIntoLocal,
42
43
  metaFromDoc,
43
44
  movedToFromDoc,
45
+ noteAnnotationsOnDisk,
44
46
  readLaneFromDoc,
45
47
  stampAnnotationsEdit,
46
48
  stampBodyEdit,
@@ -84,7 +86,7 @@ export interface ProjectionPaths {
84
86
  html: string;
85
87
  /** Absolute path to `_comments/<slug>.json`. */
86
88
  comments: string;
87
- /** Absolute path to `<slug>.annotations.svg`. */
89
+ /** Absolute path to `<slug>.annotations.json` (DDR-242). */
88
90
  annotations: string;
89
91
  /** Absolute path to the canvas `.meta.json` sibling (optional). */
90
92
  meta?: string;
@@ -449,6 +451,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
449
451
  * fire must never cost the write that already succeeded.
450
452
  */
451
453
  function writeAndAnnounce(path: string, value: string): void {
454
+ // The board disk holds from here on — the base a later file event is
455
+ // imported against (codec importAnnotationsFromDisk).
456
+ if (path === paths.annotations) noteAnnotationsOnDisk(doc, value);
452
457
  if (readLocal(path) === value) return;
453
458
  writer(path, value);
454
459
  try {
@@ -1159,7 +1164,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
1159
1164
  // survives cold start on other peers (the 2026-08-14 eraser fix).
1160
1165
  let changed = false;
1161
1166
  doc.transact(() => {
1162
- changed = applyAnnotationsToDoc(doc, str, importOrigin);
1167
+ changed = importAnnotationsFromDisk(doc, str, importOrigin);
1163
1168
  if (changed) stampAnnotationsEdit(doc, importOrigin);
1164
1169
  }, importOrigin);
1165
1170
  return changed;