@1agh/maude 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +533 -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.ts +474 -0
  21. package/apps/studio/annotations/registry.ts +159 -0
  22. package/apps/studio/annotations/replica.ts +264 -0
  23. package/apps/studio/annotations/scene.ts +235 -0
  24. package/apps/studio/annotations/schema.ts +188 -0
  25. package/apps/studio/annotations/types.ts +77 -0
  26. package/apps/studio/annotations/ui/board.ts +126 -0
  27. package/apps/studio/annotations/ui/containment.ts +118 -0
  28. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  29. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  30. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  31. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  32. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  33. package/apps/studio/annotations/ui/render-model.ts +264 -0
  34. package/apps/studio/annotations/ui/scene.tsx +63 -0
  35. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  36. package/apps/studio/annotations/ui/text-session.ts +140 -0
  37. package/apps/studio/annotations/ui/text-style.ts +111 -0
  38. package/apps/studio/annotations/ui/world.ts +47 -0
  39. package/apps/studio/annotations/v1-adapter.ts +428 -0
  40. package/apps/studio/annotations-align.ts +21 -6
  41. package/apps/studio/annotations-bindings.ts +2 -0
  42. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  43. package/apps/studio/annotations-groups.ts +3 -0
  44. package/apps/studio/annotations-layer.tsx +923 -1925
  45. package/apps/studio/annotations-model.ts +97 -4
  46. package/apps/studio/annotations-sync.ts +4 -47
  47. package/apps/studio/api.ts +262 -68
  48. package/apps/studio/bin/_import-figma.mjs +22 -12
  49. package/apps/studio/bin/annotate.mjs +331 -838
  50. package/apps/studio/bin/annotate.sh +4 -4
  51. package/apps/studio/bin/perf.sh +21 -7
  52. package/apps/studio/bin/read-annotations.mjs +184 -666
  53. package/apps/studio/bin/read-annotations.sh +9 -5
  54. package/apps/studio/canvas-artifacts.ts +9 -0
  55. package/apps/studio/canvas-comment-mount.tsx +35 -2
  56. package/apps/studio/canvas-lib.tsx +18 -1
  57. package/apps/studio/canvas-shell.tsx +54 -1
  58. package/apps/studio/client/app.jsx +101 -35
  59. package/apps/studio/client/comments-overlay.css +10 -0
  60. package/apps/studio/client/hmr.mjs +1 -1
  61. package/apps/studio/client/panels/git-grouping.js +2 -2
  62. package/apps/studio/client/tree-expansion.js +217 -0
  63. package/apps/studio/collab/index.ts +58 -5
  64. package/apps/studio/collab/persistence.ts +80 -11
  65. package/apps/studio/collab/registry.ts +63 -26
  66. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  67. package/apps/studio/comment-anchor.ts +116 -0
  68. package/apps/studio/comments-overlay.tsx +78 -35
  69. package/apps/studio/context.ts +1 -0
  70. package/apps/studio/cursors-overlay.tsx +337 -109
  71. package/apps/studio/dist/client.bundle.js +850 -850
  72. package/apps/studio/dist/comment-mount.js +2 -2
  73. package/apps/studio/figma/to-strokes.ts +31 -10
  74. package/apps/studio/git/endpoints.ts +1 -1
  75. package/apps/studio/git/service.ts +1 -1
  76. package/apps/studio/git/watch.ts +1 -1
  77. package/apps/studio/http.ts +90 -15
  78. package/apps/studio/server.ts +16 -0
  79. package/apps/studio/sync/accepted-cold-start.ts +72 -8
  80. package/apps/studio/sync/agent.ts +36 -6
  81. package/apps/studio/sync/codec.ts +95 -40
  82. package/apps/studio/sync/comment-ledger.ts +229 -0
  83. package/apps/studio/sync/file-membership.ts +12 -2
  84. package/apps/studio/sync/file-plane.ts +78 -2
  85. package/apps/studio/sync/index.ts +75 -16
  86. package/apps/studio/sync/journal-client.ts +5 -0
  87. package/apps/studio/sync/limits.ts +7 -2
  88. package/apps/studio/sync/migrate-seed.ts +17 -7
  89. package/apps/studio/sync/projection.ts +7 -2
  90. package/apps/studio/sync/remote-docs.ts +34 -0
  91. package/apps/studio/sync/writer-registry.ts +10 -0
  92. package/apps/studio/text-caret.ts +11 -2
  93. package/apps/studio/tree-state.ts +45 -0
  94. package/apps/studio/undo-stack.ts +2 -2
  95. package/apps/studio/use-annotation-resize.tsx +48 -23
  96. package/apps/studio/use-annotation-selection.tsx +9 -2
  97. package/apps/studio/use-collab.tsx +104 -1
  98. package/apps/studio/use-selection-set.tsx +4 -0
  99. package/apps/studio/whats-new.json +63 -0
  100. package/cli/lib/design-link.mjs +5 -1
  101. package/cli/lib/gitignore-block.mjs +1 -1
  102. package/cli/lib/gitignore-drift.mjs +2 -1
  103. package/package.json +9 -8
  104. package/plugins/design/templates/brief-board.tsx.template +1 -1
  105. package/apps/studio/annotation-edit-base.ts +0 -36
  106. 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
@@ -18,16 +18,38 @@
18
18
  // A canvas the project does not know yet is proposed as `doc.create` with all
19
19
  // of its lanes, so it arrives on every peer as ONE action.
20
20
  //
21
- // Comments and annotations are never re-proposed from disk here: every change
22
- // the studio made to them went through the durable outbox (drained before any
23
- // cold start), so a difference on disk is an older accepted state the room had
24
- // not re-projected — the accepted value wins, and the local file is kept in a
21
+ // Annotations are never re-proposed from disk here: every change the studio
22
+ // made to them went through the durable outbox (drained before any cold start),
23
+ // so a difference on disk is an older accepted state the room had not
24
+ // re-projected — the accepted value wins, and the local file is kept in a
25
25
  // recovery slot in case a raw edit made while the studio was down lived there.
26
+ //
27
+ // Comments had the same rule, and the premise did not hold for them (issue
28
+ // #133, reproduced end-to-end): a comment added while the sync runtime was not
29
+ // up yet (`proposeLane` unavailable, server.ts) went to the local room and disk
30
+ // only, never into the outbox — so the web never saw it, and the file then
31
+ // froze behind it. Comments carry stable ids, so the difference is decidable:
32
+ // an id on disk that the accepted state lacks and that the comment ledger says
33
+ // was never synced from here is a local comment still owed to the project; it
34
+ // is proposed, as the accepted list plus those comments (a three-way id merge
35
+ // on the hub). Ids the ledger knows were synced are deletions made while this
36
+ // machine was away and are not proposed back. With no ledger record for the
37
+ // canvas (first launch after upgrading) nothing is decidable: the old rule —
38
+ // accepted wins, local kept in recovery — stands.
26
39
 
27
40
  import { existsSync, readFileSync } from 'node:fs';
28
41
  import type * as Y from 'yjs';
29
42
 
30
- 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';
51
+ import { commentKey } from './comment-identity.ts';
52
+ import type { CommentLedger } from './comment-ledger.ts';
31
53
  import { hashBytes } from './echo-guard.ts';
32
54
  import type { SyncJournal } from './journal.ts';
33
55
  import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
@@ -53,6 +75,38 @@ function readText(p: string | undefined): string | null {
53
75
  }
54
76
  }
55
77
 
78
+ function parseList(v: string): unknown[] {
79
+ if (!v) return [];
80
+ try {
81
+ const parsed = JSON.parse(v) as unknown;
82
+ return Array.isArray(parsed) ? parsed : [];
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The comments list to propose at cold start — the accepted list plus the local
90
+ * comments never synced from here — or null when there is nothing owed (or no
91
+ * ledger record to decide by). See the header.
92
+ */
93
+ export function commentsOwedToProject(
94
+ local: string,
95
+ accepted: string,
96
+ slug: string,
97
+ ledger: CommentLedger | undefined
98
+ ): string | null {
99
+ if (!ledger?.known(slug)) return null;
100
+ const acceptedList = parseList(accepted);
101
+ const inProject = new Set(acceptedList.map(commentKey));
102
+ const synced = ledger.get(slug);
103
+ const owed = parseList(local).filter((c) => {
104
+ const k = commentKey(c);
105
+ return !inProject.has(k) && !synced.has(k);
106
+ });
107
+ return owed.length > 0 ? JSON.stringify([...acceptedList, ...owed]) : null;
108
+ }
109
+
56
110
  /**
57
111
  * Pure decision for the source lanes. `knownBase` is the last value disk and
58
112
  * the accepted replica agreed on (recovery slot), `baseHash` the journal's
@@ -92,6 +146,8 @@ export interface AcceptedColdStartInput {
92
146
  projection: DocProjection;
93
147
  historyDir?: string;
94
148
  journal?: SyncJournal;
149
+ /** Issue #133 — comment ids synced from here before (see the header). */
150
+ commentLedger?: CommentLedger;
95
151
  /**
96
152
  * This disk's own html proposal a previous run left unanswered, which the
97
153
  * outbox drain has just had accepted. The disk was saved on top of it, so it
@@ -131,8 +187,9 @@ export async function acceptedColdStart(
131
187
  const metaText = readText(i.paths.meta);
132
188
  const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
133
189
  if (meta && meta !== '{}') lanes.meta = meta;
134
- const ann = readText(i.paths.annotations);
135
- 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;
136
193
  const commentsText = readText(i.paths.comments);
137
194
  const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
138
195
  if (comments) lanes.comments = comments;
@@ -218,13 +275,20 @@ export async function acceptedColdStart(
218
275
  // ---- comments / annotations — the accepted value wins (see header)
219
276
  for (const lane of ['comments', 'annotations'] as const) {
220
277
  const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
221
- const text = readText(p);
278
+ const text = lane === 'annotations' ? readLocalAnnotations(p, readText) : readText(p);
222
279
  const local = text === null ? null : laneValueFromFile(lane, text);
223
280
  const accepted = readLaneFromDoc(i.doc, lane);
224
281
  if (local === null || local === accepted) {
225
282
  verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
226
283
  continue;
227
284
  }
285
+ if (lane === 'comments') {
286
+ const owed = commentsOwedToProject(local, accepted, i.slug, i.commentLedger);
287
+ if (owed) {
288
+ verdicts.push({ lane, decision: 'propose', base: accepted, local: owed });
289
+ continue;
290
+ }
291
+ }
228
292
  if (i.historyDir && text) {
229
293
  try {
230
294
  saveRecoveryBody(i.historyDir, p, 'local', text);
@@ -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,
@@ -67,7 +70,8 @@ import {
67
70
  unionCommentsById,
68
71
  } from './cold-start.ts';
69
72
  import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
70
- import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
73
+ import { commentKey, dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
74
+ import { type CommentLedger, withoutRemotelyDeleted } from './comment-ledger.ts';
71
75
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
72
76
  import type { SyncJournal } from './journal.ts';
73
77
  import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
@@ -87,7 +91,7 @@ export interface CanvasSyncPaths {
87
91
  html: string;
88
92
  /** Absolute path to <designRoot>/_comments/<slug>.json. */
89
93
  comments: string;
90
- /** Absolute path to <designRoot>/<slug>.annotations.svg. */
94
+ /** Absolute path to <designRoot>/<slug>.annotations.json (DDR-242). */
91
95
  annotations: string;
92
96
  /** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
93
97
  * when set (always, in production wiring), shared meta keys (layout/artboards)
@@ -119,6 +123,20 @@ export interface CanvasSyncAgentOptions {
119
123
  * without it simply degrade to the conservative conflict path.
120
124
  */
121
125
  journal?: SyncJournal;
126
+ /**
127
+ * Issue #133 — comment ids synced from this machine before
128
+ * (sync/comment-ledger.ts). The cold-start union drops a local comment the
129
+ * ledger knows and the hub's doc no longer holds: that is a delete made
130
+ * while this machine was away, not a local-only comment. Optional — absent,
131
+ * the union keeps everything, as before.
132
+ */
133
+ commentLedger?: CommentLedger;
134
+ /**
135
+ * Does the hub hold this doc's current state (nothing unacknowledged)? The
136
+ * ledger records comment ids as synced only then — see
137
+ * `commentsConfirmedOnHub` in sync/index.ts. Absent → never records.
138
+ */
139
+ commentsConfirmed?: () => boolean;
122
140
  /**
123
141
  * Snapshot writer (DDR-102 conflict protocol) — persists a body version to
124
142
  * `_history/<slug>/` and resolves with the snapshot's ISO ts (null on
@@ -339,6 +357,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
339
357
  echoGuard.record(paths.comments, hash);
340
358
  writer(paths.comments, serialized);
341
359
  lastComments = serialized;
360
+ if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, next.map(commentKey));
342
361
  }
343
362
 
344
363
  function writeAnnotationsIfChanged(): void {
@@ -352,6 +371,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
352
371
  }
353
372
  const hash = hashBytes(value);
354
373
  echoGuard.record(paths.annotations, hash);
374
+ noteAnnotationsOnDisk(doc, value);
355
375
  writer(paths.annotations, value);
356
376
  lastAnnotations = value;
357
377
  }
@@ -411,6 +431,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
411
431
  if (parsed === null) return false;
412
432
  const changed = applyCommentsToDoc(doc, parsed, origin);
413
433
  if (changed) lastComments = str;
434
+ if (opts.commentsConfirmed?.())
435
+ opts.commentLedger?.record(slug, commentsFromDoc(doc).map(commentKey));
414
436
  return changed;
415
437
  }
416
438
  if (evt.path === paths.annotations) {
@@ -420,7 +442,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
420
442
  // wrapper written by saveAnnotations) cold-start-safe on other peers.
421
443
  let changed = false;
422
444
  doc.transact(() => {
423
- changed = applyAnnotationsToDoc(doc, str, origin);
445
+ changed = importAnnotationsFromDisk(doc, str, origin, lastAnnotations);
424
446
  if (changed) stampAnnotationsEdit(doc, origin);
425
447
  }, origin);
426
448
  if (changed) lastAnnotations = str;
@@ -452,7 +474,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
452
474
  if (movedToFromDoc(doc) !== null) return;
453
475
  const localHtml = readLocal(paths.html);
454
476
  const localComments = readLocal(paths.comments);
455
- const localAnnotations = readLocal(paths.annotations);
477
+ const localAnnotations = readLocalAnnotations(paths.annotations, readLocal);
456
478
  const localMeta = paths.meta ? readLocal(paths.meta) : null;
457
479
  const localCss = paths.css ? readLocal(paths.css) : null;
458
480
 
@@ -562,7 +584,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
562
584
  const bodyWinner = applied.bodyWinner;
563
585
 
564
586
  // ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
565
- const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
587
+ const localParsed = localComments !== null ? tryParseJsonArray(localComments) : null;
588
+ // Issue #133 — a local comment that was synced from here before and that
589
+ // the hub's doc no longer holds was deleted while this machine was away.
590
+ // Unioning it back resurrected it for everyone.
591
+ const localParsedComments = localParsed
592
+ ? withoutRemotelyDeleted(localParsed, docComments, opts.commentLedger?.get(slug))
593
+ : null;
566
594
  if (localParsedComments !== null && localParsedComments.length > 0) {
567
595
  const merged = unionCommentsById(docComments, localParsedComments);
568
596
  const mergedStr = merged.length > 0 ? `${JSON.stringify(merged, null, 2)}\n` : '';
@@ -573,6 +601,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
573
601
  writer(paths.comments, mergedStr);
574
602
  }
575
603
  lastComments = mergedStr;
604
+ if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, merged.map(commentKey));
576
605
  } else {
577
606
  // No (parseable) local comments — hub state materializes as before.
578
607
  lastComments = docCommentsStr;
@@ -581,6 +610,7 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
581
610
  echoGuard.record(paths.comments, hash);
582
611
  writer(paths.comments, docCommentsStr);
583
612
  }
613
+ if (opts.commentsConfirmed?.()) opts.commentLedger?.record(slug, docComments.map(commentKey));
584
614
  }
585
615
 
586
616
  // ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix) -----
@@ -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,17 @@ 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 {
32
+ annotationsOnDiskOf,
33
+ isEmptyBoardText,
34
+ noteAnnotationsOnDisk,
35
+ readReplica,
36
+ replicaBoardText,
37
+ writeReplica,
38
+ } from '../annotations/replica.ts';
39
+ import { parseBoard, serializeBoard } from '../annotations/schema.ts';
29
40
  import { Y_TYPES } from '../collab/persistence.ts';
30
41
  import { commentKey, dedupeCommentsById } from './comment-identity.ts';
31
42
  import {
@@ -293,30 +304,84 @@ export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?:
293
304
 
294
305
  /* ---------------------------------------------------------------- annotations */
295
306
 
296
- /** Returns the annotations SVG string, or null if unset. */
307
+ export { canonicalAnnotations };
308
+
309
+ /**
310
+ * The local board (canonical text) at `jsonPath`, falling back to a not-yet-
311
+ * migrated legacy `.annotations.svg` sibling — so a cold start that races the
312
+ * boot migration still sees the local strokes instead of "no local" (which lets
313
+ * the hub win by default: the DDR-223 eraser shape). null = neither exists.
314
+ */
315
+ export function readLocalAnnotations(
316
+ jsonPath: string,
317
+ read: (abs: string) => string | null
318
+ ): string | null {
319
+ const local = read(jsonPath);
320
+ if (local !== null) return canonicalAnnotations(local) ?? local;
321
+ const legacy = read(jsonPath.replace(/\.json$/, '.svg'));
322
+ return legacy === null ? null : canonicalAnnotations(legacy);
323
+ }
324
+
325
+ /** The board held by the doc as canonical text, or null if never populated. */
297
326
  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;
327
+ return replicaBoardText(doc);
301
328
  }
302
329
 
303
330
  /**
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).
331
+ * True when an annotations value carries ZERO elements: null, `''`, an empty
332
+ * board, or the bare legacy wrapper `<svg …></svg>`.
307
333
  *
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.
334
+ * Load-bearing for cold start (the 2026-08-14 annotations eraser, DDR-223): the
335
+ * legacy wrapper was a non-empty STRING, so every `!== ''` guard let a stale hub
336
+ * wrapper overwrite a peer's real strokes. Emptiness is "zero elements", never a
337
+ * byte heuristic. Live delete-all still materializes; only COLD-START decisions
338
+ * treat this as emptiness.
339
+ */
340
+ export function isEmptyAnnotationsSvg(text: string | null): boolean {
341
+ return isEmptyBoardText(text);
342
+ }
343
+
344
+ /**
345
+ * Make the doc's replica hold `next` (board text or legacy SVG), writing only
346
+ * the elements / fields that differ. `null` / `''` → an empty board. A
347
+ * filesystem import is a new operation, never the previous UI author's echo:
348
+ * `writeReplica` clears '~action' on any change made without an action id.
349
+ */
350
+ /**
351
+ * A disk change to the board, imported into the doc as the CHANGE it made —
352
+ * the ops from what disk last held to what it holds now — never as a
353
+ * replacement. A projection written a moment before a newer edit, whose file
354
+ * event arrives after it, would otherwise put the older values back (the
355
+ * multiplayer rig's lost toolbar edits and deletes). Without a known base
356
+ * (first sight of the file) it is a full apply. Returns whether the doc changed.
315
357
  */
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);
358
+ export { noteAnnotationsOnDisk };
359
+
360
+ export function importAnnotationsFromDisk(
361
+ doc: Y.Doc,
362
+ next: string | null,
363
+ origin?: unknown,
364
+ fallbackBase?: string | null
365
+ ): boolean {
366
+ const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
367
+ if (text === null || byteLengthUtf8(text) > MAX_ANNOTATIONS_BYTES) {
368
+ return applyAnnotationsToDoc(doc, next, origin);
369
+ }
370
+ const known = annotationsOnDiskOf(doc);
371
+ const base =
372
+ (known !== undefined ? canonicalAnnotations(known) : null) ??
373
+ (fallbackBase ? canonicalAnnotations(fallbackBase) : null);
374
+ const cur = readReplica(doc);
375
+ noteAnnotationsOnDisk(doc, text);
376
+ if (base === null || cur === null) {
377
+ return applyAnnotationsToDoc(doc, text, origin);
378
+ }
379
+ const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
380
+ const ops = diffToOps(toMap(base), toMap(text));
381
+ if (!ops.length) return false;
382
+ const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
383
+ if (!r.touched.size) return false;
384
+ return writeReplica(doc, [...r.state.values()], origin);
320
385
  }
321
386
 
322
387
  export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
@@ -326,21 +391,13 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
326
391
  );
327
392
  return false;
328
393
  }
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;
394
+ const text = next === null ? serializeBoard([]) : canonicalAnnotations(next);
395
+ if (text === null) {
396
+ console.warn('[sync/codec] refusing annotations apply: not a board document');
397
+ return false;
398
+ }
399
+ if (readReplica(doc) !== null && replicaBoardText(doc) === text) return false;
400
+ return writeReplica(doc, parseBoard(text).elements, origin);
344
401
  }
345
402
 
346
403
  /* ---------------------------------------------------------------- meta */
@@ -426,7 +483,8 @@ export function laneValueFromFile(
426
483
  lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
427
484
  text: string
428
485
  ): string | null {
429
- if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
486
+ if (lane === 'html' || lane === 'css') return text;
487
+ if (lane === 'annotations') return annotationsLaneValue(text);
430
488
  let parsed: unknown;
431
489
  try {
432
490
  parsed = parseJsonSafe(text);
@@ -448,10 +506,7 @@ export function readLaneFromDoc(
448
506
  lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
449
507
  ): string {
450
508
  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
- }
509
+ if (lane === 'annotations') return annotationsLaneValue(replicaBoardText(doc) ?? '') ?? '';
455
510
  const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
456
511
  return list.length ? JSON.stringify(list) : '';
457
512
  }