@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
@@ -0,0 +1,217 @@
1
+ // tree-expansion.js — which folders + sections of the Files panel are open
2
+ // (issue #124).
3
+ //
4
+ // Before this, every folder row owned its own `useState(defaultOpen=true)`:
5
+ // the tree started fully expanded and forgot every collapse the moment the row
6
+ // unmounted — a dock-tab switch, a section toggle, a reload. The state now
7
+ // lives ONCE at App level (so it outlives the rows) and is persisted per
8
+ // project through `/_api/tree-state` (so it outlives the app).
9
+ //
10
+ // Rule: anything not recorded is CLOSED — folders and sections alike. Nothing
11
+ // expands itself on boot; only the user does (a click, or explicitly opening a
12
+ // canvas, which reveals its row). Search force-opens without recording.
13
+ //
14
+ // The helpers below are pure (state in → state out) so they are unit-testable
15
+ // without a DOM; `useTreeExpansion` is the thin React wrapper App uses.
16
+
17
+ import { useCallback, useEffect, useRef, useState } from 'react';
18
+
19
+ export const EMPTY_TREE_STATE = Object.freeze({ dirs: [], sections: {} });
20
+
21
+ // Pre-#124 section overrides lived only in origin-scoped localStorage. Read
22
+ // once as a seed so an existing user's choices survive the move to disk.
23
+ export const LEGACY_SECTIONS_STORE = 'mdcc-sections-expanded';
24
+
25
+ const PERSIST_DEBOUNCE_MS = 250;
26
+ const HYDRATE_TIMEOUT_MS = 3000;
27
+
28
+ export function isDirOpen(state, dirPath) {
29
+ return !!dirPath && state.dirs.includes(dirPath);
30
+ }
31
+
32
+ export function isSectionOpen(state, label) {
33
+ return state.sections[label] === true;
34
+ }
35
+
36
+ export function setDirOpen(state, dirPath, open) {
37
+ if (!dirPath) return state;
38
+ const has = state.dirs.includes(dirPath);
39
+ if (has === !!open) return state;
40
+ const dirs = open ? [...state.dirs, dirPath].sort() : state.dirs.filter((d) => d !== dirPath);
41
+ return { ...state, dirs };
42
+ }
43
+
44
+ export function toggleDir(state, dirPath) {
45
+ return setDirOpen(state, dirPath, !isDirOpen(state, dirPath));
46
+ }
47
+
48
+ export function setSectionOpen(state, label, open) {
49
+ if (!label || state.sections[label] === !!open) return state;
50
+ return { ...state, sections: { ...state.sections, [label]: !!open } };
51
+ }
52
+
53
+ export function toggleSection(state, label) {
54
+ return setSectionOpen(state, label, !isSectionOpen(state, label));
55
+ }
56
+
57
+ /** The group (section) a repo-relative path lives in — longest `fullPath` wins. */
58
+ export function groupForPath(groups, path) {
59
+ let best = null;
60
+ for (const g of groups || []) {
61
+ const root = g.fullPath;
62
+ if (!root || !(path === root || path.startsWith(root + '/'))) continue;
63
+ if (!best || root.length > best.fullPath.length) best = g;
64
+ }
65
+ return best;
66
+ }
67
+
68
+ /**
69
+ * Open everything needed to SEE `path`: its section, and every folder between
70
+ * the section root and the path. `includeSelf` also opens `path` itself (for a
71
+ * folder target — a new folder's parent, a move destination).
72
+ */
73
+ export function revealPath(state, groups, path, { includeSelf = false } = {}) {
74
+ if (!path) return state;
75
+ const g = groupForPath(groups, path);
76
+ if (!g) return state;
77
+ let next = setSectionOpen(state, g.label, true);
78
+ const rest = path.slice(g.fullPath.length + 1);
79
+ if (!rest) return next;
80
+ const parts = rest.split('/');
81
+ const upto = includeSelf ? parts.length : parts.length - 1;
82
+ let cur = g.fullPath;
83
+ for (let i = 0; i < upto; i++) {
84
+ cur = `${cur}/${parts[i]}`;
85
+ next = setDirOpen(next, cur, true);
86
+ }
87
+ return next;
88
+ }
89
+
90
+ /** A folder moved/renamed `from` → `to`: carry its (and its subfolders') keys. */
91
+ export function remapDirPrefix(state, from, to) {
92
+ if (!from || !to || from === to) return state;
93
+ let changed = false;
94
+ const dirs = state.dirs.map((d) => {
95
+ if (d === from) {
96
+ changed = true;
97
+ return to;
98
+ }
99
+ if (d.startsWith(from + '/')) {
100
+ changed = true;
101
+ return to + d.slice(from.length);
102
+ }
103
+ return d;
104
+ });
105
+ return changed ? { ...state, dirs: [...new Set(dirs)].sort() } : state;
106
+ }
107
+
108
+ /** Every folder path present in the loaded groups' trees. */
109
+ export function collectDirPaths(groups) {
110
+ const out = new Set();
111
+ const walk = (node, base) => {
112
+ for (const k of Object.keys(node || {})) {
113
+ if (k === '_files') continue;
114
+ const p = `${base}/${k}`;
115
+ out.add(p);
116
+ walk(node[k], p);
117
+ }
118
+ };
119
+ for (const g of groups || []) if (g.fullPath) walk(g.tree, g.fullPath);
120
+ return out;
121
+ }
122
+
123
+ /** Drop keys for folders that no longer exist (deleted, moved outside Maude). */
124
+ export function pruneDirs(state, known) {
125
+ const dirs = state.dirs.filter((d) => known.has(d));
126
+ return dirs.length === state.dirs.length ? state : { ...state, dirs };
127
+ }
128
+
129
+ export function normalizeClientState(raw) {
130
+ const o = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
131
+ const dirs = Array.isArray(o.dirs) ? [...new Set(o.dirs.filter((d) => typeof d === 'string' && d))].sort() : [];
132
+ const sections = {};
133
+ if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
134
+ for (const [k, v] of Object.entries(o.sections)) if (typeof v === 'boolean') sections[k] = v;
135
+ }
136
+ return { dirs, sections };
137
+ }
138
+
139
+ function readLegacySections() {
140
+ try {
141
+ const v = JSON.parse(localStorage.getItem(LEGACY_SECTIONS_STORE) || 'null');
142
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * App-level owner of the tree state. `ready` flips once the stored state has
150
+ * been read (or the read failed) — App holds the tree back until then so the
151
+ * first paint is the remembered tree, not an all-closed flash. Writes are
152
+ * debounced and only start after hydration, so the initial empty state can
153
+ * never overwrite what is on disk.
154
+ */
155
+ export function useTreeExpansion({ fetchImpl } = {}) {
156
+ const [state, setState] = useState(EMPTY_TREE_STATE);
157
+ const [ready, setReady] = useState(false);
158
+ const doFetch = fetchImpl || ((...a) => fetch(...a));
159
+ const fetchRef = useRef(doFetch);
160
+ fetchRef.current = doFetch;
161
+
162
+ useEffect(() => {
163
+ let cancelled = false;
164
+ // A stalled read (cell cold start, a hung proxy) must not keep the Files
165
+ // panel empty: after HYDRATE_TIMEOUT_MS the tree renders all-closed.
166
+ const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
167
+ const timer = setTimeout(() => ctrl?.abort(), HYDRATE_TIMEOUT_MS);
168
+ fetchRef
169
+ .current('/_api/tree-state', ctrl ? { signal: ctrl.signal } : undefined)
170
+ .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
171
+ .then((raw) => {
172
+ if (cancelled) return;
173
+ let next = normalizeClientState(raw);
174
+ const stored = raw && typeof raw === 'object' && raw.sections && Object.keys(raw.sections).length;
175
+ if (!stored) {
176
+ const legacy = readLegacySections();
177
+ if (legacy) next = normalizeClientState({ ...next, sections: legacy });
178
+ }
179
+ setState(next);
180
+ })
181
+ .catch(() => {})
182
+ .finally(() => {
183
+ clearTimeout(timer);
184
+ if (!cancelled) setReady(true);
185
+ });
186
+ return () => {
187
+ cancelled = true;
188
+ clearTimeout(timer);
189
+ };
190
+ }, []);
191
+
192
+ const skipFirst = useRef(true);
193
+ useEffect(() => {
194
+ if (!ready) return;
195
+ // The hydrating setState lands in the same commit as `ready`; that value
196
+ // came FROM disk, so writing it back is pointless.
197
+ if (skipFirst.current) {
198
+ skipFirst.current = false;
199
+ return;
200
+ }
201
+ const t = setTimeout(() => {
202
+ try {
203
+ fetchRef
204
+ .current('/_api/tree-state', {
205
+ method: 'POST',
206
+ headers: { 'content-type': 'text/plain' },
207
+ body: JSON.stringify(state),
208
+ })
209
+ .catch(() => {});
210
+ } catch {}
211
+ }, PERSIST_DEBOUNCE_MS);
212
+ return () => clearTimeout(t);
213
+ }, [ready, state]);
214
+
215
+ const update = useCallback((fn) => setState((s) => fn(s)), []);
216
+ return { state, ready, update };
217
+ }
@@ -4,6 +4,9 @@
4
4
  import { readFileSync, statSync } from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
+ import { canonicalAnnotations } from '../annotations/board-text.ts';
8
+ import { diffToOps } from '../annotations/ops.ts';
9
+ import { parseBoard } from '../annotations/schema.ts';
7
10
  import type { Api } from '../api.ts';
8
11
  import type { Context } from '../context.ts';
9
12
 
@@ -59,11 +62,37 @@ export function createCollab(ctx: Context, api: Api): Collab {
59
62
  // (the predicate is only consulted at room-seed time, after `registry` is
60
63
  // assigned). Flag-OFF → predicate returns true → seed unchanged.
61
64
  let registryRef: Registry | null = null;
65
+ // Board texts this process's rooms projected to disk and whose watcher echo
66
+ // hasn't come back yet (a few per slug: flushes can outrun the watcher).
67
+ const ownProjections = new Map<string, string[]>();
68
+ // The board as disk last held it while the room agreed with it (the room's
69
+ // own projection, or an external write it imported). An external write is
70
+ // applied as the CHANGE from this base, never as a replacement: a room edit
71
+ // not yet flushed (a delete, a move) survives a writer that didn't know it.
72
+ const diskBase = new Map<string, string>();
62
73
  const persistence = createPersistence({
63
74
  ctx,
64
75
  api,
65
76
  fileForSlug,
77
+ onAnnotationsProjected: (slug, board) => {
78
+ const list = ownProjections.get(slug) ?? [];
79
+ list.push(board);
80
+ ownProjections.set(slug, list.slice(-4));
81
+ diskBase.set(slug, board);
82
+ },
83
+ onAnnotationsSeeded: (slug, board) => {
84
+ diskBase.set(slug, board);
85
+ },
66
86
  shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
87
+ // Issue #133 — record comment ids as synced only once the hub holds them,
88
+ // and only for the room that IS the hub's doc (pinned). No hub linked:
89
+ // nothing is "synced" — recording then would label local comments with
90
+ // the last hub's identity and a relink would drop them (security review
91
+ // F1a). Linked but the runtime is not up yet: not confirmed.
92
+ commentsConfirmed: (slug) => {
93
+ if (!ctx.cfg?.linkedHub || !registryRef?.isPinned(slug)) return false;
94
+ return ctx.syncControl?.current?.()?.commentsConfirmedOnHub?.(slug) === true;
95
+ },
67
96
  // A room restored from its own `.ydoc.bin` must not outrank a sidecar the
68
97
  // hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
69
98
  // Only for rooms no hub provider owns: a pinned doc is the hub's replica
@@ -79,9 +108,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
79
108
  };
80
109
  const file = await fileForSlug(slug);
81
110
  if (!file) return;
82
- if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.svg`))) {
83
- const svg = await api.loadAnnotations(file);
84
- if (svg) applyAnnotationsToDoc(doc, svg, 'seed');
111
+ if (newerThanCache(path.join(ctx.paths.designRoot, `${slug}.annotations.json`))) {
112
+ const board = await api.loadAnnotations(file);
113
+ if (board) applyAnnotationsToDoc(doc, board, 'seed');
85
114
  }
86
115
  if (newerThanCache(path.join(ctx.paths.commentsDir, `${slug}.json`))) {
87
116
  applyCommentsToDoc(doc, await api.loadCommentsForFile(file), 'seed');
@@ -115,7 +144,9 @@ export function createCollab(ctx: Context, api: Api): Collab {
115
144
  registry.peek(slug) !== null && !registry.isPinned(slug);
116
145
  const reseedFromDisk = async (rel: string): Promise<void> => {
117
146
  const cm = /^_comments\/(.+)\.json$/.exec(rel);
118
- const am = /^(.+)\.annotations\.svg$/.exec(rel);
147
+ // DDR-242 — the board file. A legacy `.annotations.svg` reappearing on disk
148
+ // is NOT a board write (the boot migration quarantines it).
149
+ const am = /^(.+)\.annotations\.json$/.exec(rel);
119
150
  const slug = cm?.[1] ?? am?.[1];
120
151
  if (!slug) return;
121
152
  const abs = path.join(ctx.paths.designRoot, rel);
@@ -140,7 +171,29 @@ export function createCollab(ctx: Context, api: Api): Collab {
140
171
  if (ownsRoomFromDisk(slug)) registry.syncRoomFromComments(slug, parsed);
141
172
  if (file) ctx.bus.emit('comments', { file, comments: parsed });
142
173
  } else if (ownsRoomFromDisk(slug)) {
143
- registry.syncRoomFromAnnotations(slug, readFileSync(abs, 'utf8'));
174
+ const text = readFileSync(abs, 'utf8');
175
+ // The room's own projection coming back: the room is at or AHEAD of
176
+ // it (an op may have landed since), so re-seeding from it would revert
177
+ // that op. Consumed once, so a later external write of the same bytes
178
+ // still re-seeds.
179
+ const own = ownProjections.get(slug);
180
+ const at = own ? own.indexOf(canonicalAnnotations(text) ?? '') : -1;
181
+ if (own && at >= 0) {
182
+ own.splice(0, at + 1);
183
+ return;
184
+ }
185
+ const next = canonicalAnnotations(text);
186
+ const base = diskBase.get(slug);
187
+ if (next !== null && base !== undefined) {
188
+ const toMap = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
189
+ const ops = diffToOps(toMap(base), toMap(next));
190
+ diskBase.set(slug, next);
191
+ if (!ops.length) return;
192
+ const applied = registry.applyOpsToRoom(slug, ops);
193
+ if (applied !== null && applied !== 'too-large') return;
194
+ }
195
+ if (next !== null) diskBase.set(slug, next);
196
+ registry.syncRoomFromAnnotations(slug, text);
144
197
  }
145
198
  } catch {
146
199
  /* file vanished mid-flight or unreadable — leave state as-is */
@@ -1,16 +1,19 @@
1
1
  // DDR-051 persistence wiring — bridges Room callbacks to the existing JSON
2
- // snapshots (Phase 6 _comments/<slug>.json) + Phase 5 annotations.svg + the
2
+ // snapshots (Phase 6 _comments/<slug>.json) + the annotations board (DDR-242) + the
3
3
  // new `.ydoc.bin` cache under _state/<slug>.ydoc.bin.
4
4
 
5
5
  import path from 'node:path';
6
6
 
7
7
  import * as Y from 'yjs';
8
8
 
9
+ import { noteAnnotationsOnDisk, replicaBoardText, writeReplica } from '../annotations/replica.ts';
10
+ import { parseBoard } from '../annotations/schema.ts';
9
11
  import type { Api } from '../api.ts';
10
12
  import type { Context } from '../context.ts';
11
13
  // From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
12
14
  // file, so reaching for it here would close a cycle (see sync/limits.ts).
13
15
  import { commentKey } from '../sync/comment-identity.ts';
16
+ import { type CommentLedger, commentLedgerFor } from '../sync/comment-ledger.ts';
14
17
  import { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, withinByteCap } from '../sync/limits.ts';
15
18
  import { ensureStateDir, type RoomCallbacks } from './room.ts';
16
19
 
@@ -22,6 +25,8 @@ import { ensureStateDir, type RoomCallbacks } from './room.ts';
22
25
  */
23
26
  export const Y_TYPES = {
24
27
  comments: 'comments',
28
+ /** v1 annotations map (`svg` key) — read only for lazy migration; the v2
29
+ * replica lives in 'annotations2' (annotations/replica.ts, DDR-242). */
25
30
  annotations: 'annotations',
26
31
  presentation: 'presentation',
27
32
  } as const;
@@ -60,6 +65,29 @@ export interface PersistenceDeps {
60
65
  * seed). Absent → cache-only restore, the previous behavior.
61
66
  */
62
67
  reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
68
+ /**
69
+ * Called with the board text a flush just projected to
70
+ * `<slug>.annotations.json`. The disk→room re-seed uses it to recognise the
71
+ * room's OWN write coming back through the file watcher: by then the room
72
+ * may be ahead (a later op), and re-seeding it from that echo reverted the
73
+ * later edit.
74
+ */
75
+ onAnnotationsProjected?: (slug: string, board: string) => void;
76
+ /** The board a room was seeded from — the disk state it starts out agreeing with. */
77
+ onAnnotationsSeeded?: (slug: string, board: string) => void;
78
+ /**
79
+ * Issue #133 — which comment ids this machine synced before (see
80
+ * sync/comment-ledger.ts). Defaults to the process-wide ledger for the design
81
+ * root; tests inject a fresh one per simulated launch.
82
+ */
83
+ commentLedger?: CommentLedger;
84
+ /**
85
+ * May the ledger record `slug`'s projected comments as synced? Only when the
86
+ * hub is known to hold them (see `commentsConfirmedOnHub`, sync/index.ts). A
87
+ * projection of a comment added offline would otherwise read as "synced" and
88
+ * the next cold start would drop it. Absent → always (tests, local projects).
89
+ */
90
+ commentsConfirmed?: (slug: string) => boolean;
63
91
  }
64
92
 
65
93
  /**
@@ -93,6 +121,7 @@ function withinCap(slug: string, lane: string, value: string, max: number): bool
93
121
  export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
94
122
  const { ctx, api, fileForSlug } = deps;
95
123
  const stateDir = ensureStateDir(ctx.paths.designRoot);
124
+ const ledger = deps.commentLedger ?? commentLedgerFor(ctx.paths.designRoot);
96
125
 
97
126
  // Per-slug: every comment identity this doc has EVER carried (issue #111).
98
127
  //
@@ -180,6 +209,27 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
180
209
  return ids;
181
210
  }
182
211
 
212
+ // Issue #133 (plan Task 8) — a comment that stays on this disk and out of the
213
+ // shared document is invisible to every other peer, and nothing said so: the
214
+ // report behind #133 had to be reconstructed from code. Say it once per
215
+ // change of the count, in the server log (which the in-app bug report
216
+ // attaches), and say when it clears.
217
+ const localOnlyBySlug = new Map<string, number>();
218
+ function reportLocalOnly(slug: string, n: number): void {
219
+ const prev = localOnlyBySlug.get(slug) ?? 0;
220
+ if (n === prev) return;
221
+ localOnlyBySlug.set(slug, n);
222
+ if (n > 0) {
223
+ console.warn(
224
+ `[collab/${slug}] comments: ${n} on this disk ${n === 1 ? 'is' : 'are'} not in the shared document yet — kept, not overwritten; other peers do not see ${n === 1 ? 'it' : 'them'} until ${n === 1 ? 'it arrives' : 'they arrive'}.`
225
+ );
226
+ } else {
227
+ console.log(
228
+ `[collab/${slug}] comments: every comment on this disk is in the shared document again.`
229
+ );
230
+ }
231
+ }
232
+
183
233
  function ydocBinPath(slug: string): string {
184
234
  return path.join(stateDir, `${slug}.ydoc.bin`);
185
235
  }
@@ -250,10 +300,12 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
250
300
  if (missing.length > 0) arr.push(missing);
251
301
  }
252
302
  if (svg && typeof svg === 'string') {
253
- const map = doc.getMap<string>(Y_TYPES.annotations);
254
- map.set('svg', svg);
303
+ // DDR-242 — `loadAnnotations` returns canonical board text (a legacy
304
+ // sidecar arrives already migrated); the replica takes it per element.
305
+ writeReplica(doc, parseBoard(svg).elements, 'seed');
255
306
  }
256
307
  }, 'seed');
308
+ if (svg && typeof svg === 'string') deps.onAnnotationsSeeded?.(slug, svg);
257
309
  }
258
310
 
259
311
  async function persistJson(slug: string, doc: Y.Doc): Promise<void> {
@@ -284,23 +336,40 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
284
336
  // brings that id into the doc is itself a doc update, which re-arms the
285
337
  // flush, and the next pass writes the merged state. A delete still
286
338
  // materializes — its id IS in `everSeen`, so the write proceeds.
339
+ //
340
+ // Issue #133 — `everSeen` starts empty on every launch, so an id deleted
341
+ // by a peer while this machine was closed also reads as "never carried"
342
+ // and froze the file for good. The ledger knows it was synced from here
343
+ // before: an id in the ledger and absent from the doc is a delete.
287
344
  const onDisk = await api.loadCommentsForFile(file);
288
- const behind = onDisk.some((c) => !everSeen.has(commentKey(c)));
345
+ const synced = ledger.get(slug);
346
+ const localOnly = onDisk.filter((c) => {
347
+ const k = commentKey(c);
348
+ return !everSeen.has(k) && !synced.has(k);
349
+ }).length;
350
+ const behind = localOnly > 0;
351
+ reportLocalOnly(slug, localOnly);
289
352
  if (!behind && withinCap(slug, 'comments', JSON.stringify(list), MAX_COMMENTS_BYTES)) {
290
353
  await api.saveCommentsForFile(file, list);
354
+ if (deps.commentsConfirmed?.(slug) ?? true) ledger.record(slug, list.map(commentKey));
291
355
  }
292
356
  }
293
357
 
294
- // Annotations — Y.Map.svg → annotations.svg file. Task 5.
295
- const map = doc.getMap<unknown>(Y_TYPES.annotations);
296
- const svg = map.get('svg');
297
- if (typeof svg === 'string' && svg) {
298
- if (withinCap(slug, 'annotations', svg, MAX_ANNOTATIONS_BYTES)) {
358
+ // Annotations — the replica → `<slug>.annotations.json` (DDR-242). A doc
359
+ // that was never populated (null) writes nothing, so a cold room can't
360
+ // clobber the file with emptiness (DDR-223).
361
+ const board = replicaBoardText(doc);
362
+ if (board !== null) {
363
+ if (withinCap(slug, 'annotations', board, MAX_ANNOTATIONS_BYTES)) {
299
364
  // Projection must never re-enter onAnnotationsChanged: an old flush
300
- // finishing after a new edit otherwise republishes the old SVG and
365
+ // finishing after a new edit otherwise republishes the old board and
301
366
  // rolls back every peer. The API checks freshness after async IO and
302
367
  // before its atomic rename; a later doc update schedules a new flush.
303
- await api.projectAnnotations(file, svg, () => map.get('svg') === svg);
368
+ // Recorded BEFORE the write: the watcher event may be delivered before
369
+ // this await resumes.
370
+ deps.onAnnotationsProjected?.(slug, board);
371
+ noteAnnotationsOnDisk(doc, board);
372
+ await api.projectAnnotations(file, board, () => replicaBoardText(doc) === board);
304
373
  }
305
374
  }
306
375
  }
@@ -9,10 +9,13 @@
9
9
  import type { Awareness } from 'y-protocols/awareness';
10
10
  import type * as Y from 'yjs';
11
11
 
12
- import { ANNOTATION_WRITE_ID, validAnnotationWriteId } from '../annotations-sync.ts';
13
- import { applyCommentsToDoc } from '../sync/codec.ts';
12
+ import { canonicalAnnotations } from '../annotations/board-text.ts';
13
+ import { type ApplyResult, applyOps, type Op } from '../annotations/ops.ts';
14
+ import { readReplica, validActionId, writeReplica } from '../annotations/replica.ts';
15
+ import { parseBoard, serializeBoard } from '../annotations/schema.ts';
16
+ import { applyCommentsToDoc, stampAnnotationsEdit } from '../sync/codec.ts';
17
+ import { MAX_ANNOTATIONS_BYTES } from '../sync/limits.ts';
14
18
  import { bridgeAwareness } from './awareness-bridge.ts';
15
- import { Y_TYPES } from './persistence.ts';
16
19
  import type { Room, RoomCallbacks } from './room.ts';
17
20
  import { createRoom } from './room.ts';
18
21
 
@@ -49,12 +52,23 @@ export interface Registry {
49
52
  */
50
53
  syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
51
54
  /**
52
- * Phase 8 Task 5 bridge — same shape as syncRoomFromComments but for the
53
- * `annotations` Y.Map. The PUT /_api/annotations endpoint passes the
54
- * post-write SVG; the room replaces `Y.Map.svg` so collab peers see the
55
- * updated stroke set without waiting for a cold-open re-seed.
55
+ * Same shape as syncRoomFromComments but for the annotations replica
56
+ * (DDR-242). Every annotations write passes the post-write board; the room's
57
+ * replica is diffed to it, so collab peers receive only the changed elements.
56
58
  */
57
- syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void;
59
+ syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void;
60
+ /**
61
+ * The canvas op path while a room is live (code review H1): apply the batch
62
+ * to the room's replica — the freshest state, ahead of a debounced flush —
63
+ * instead of to disk, where a peer's not-yet-flushed edit would be reverted.
64
+ * `null` when no room (or a never-populated one) is live; `'too-large'` when
65
+ * the result would exceed the board cap (nothing written).
66
+ */
67
+ applyOpsToRoom(
68
+ slug: string,
69
+ ops: readonly Op[],
70
+ actionId?: string
71
+ ): ApplyResult | 'too-large' | null;
58
72
  /**
59
73
  * Phase 30 — project agent editing-presence onto a slug's room awareness so
60
74
  * it crosses the hub (the loopback `ai-activity` bus event does not). `null`
@@ -110,27 +124,24 @@ export interface Registry {
110
124
  }
111
125
 
112
126
  /**
113
- * Replace a doc's annotation SVG — the one write shape shared by the live
114
- * disk→room bridge and the cache-restore reconcile (`collab/index.ts`).
115
- * Identical disk notifications stay no-ops (no update → no persist loop); a UI
116
- * operation may deliberately restore identical content under a new write id.
117
- * Returns whether the doc changed.
127
+ * Make a doc's annotations replica hold `text` (canonical board JSON, or a
128
+ * legacy SVG that is converted) — the one write shape shared by the live
129
+ * disk→room bridge and the cache-restore reconcile (`collab/index.ts`). Only the
130
+ * elements / fields that differ become Yjs updates (DDR-242 §5); identical
131
+ * content is a no-op (no update → no persist loop). `actionId` lets the author's
132
+ * canvas recognise its own echo. Returns whether the doc changed.
118
133
  */
119
134
  export function applyAnnotationsToDoc(
120
135
  doc: Y.Doc,
121
- svg: string,
136
+ text: string,
122
137
  origin: unknown,
123
- writeId?: string
138
+ actionId?: string
124
139
  ): boolean {
125
- const map = doc.getMap<string>(Y_TYPES.annotations);
126
- const id = validAnnotationWriteId(writeId) ? writeId : undefined;
127
- if (map.get('svg') === svg && (!id || map.get(ANNOTATION_WRITE_ID) === id)) return false;
128
- doc.transact(() => {
129
- map.set('svg', svg);
130
- if (id) map.set(ANNOTATION_WRITE_ID, id);
131
- else map.delete(ANNOTATION_WRITE_ID);
132
- }, origin);
133
- return true;
140
+ const board = canonicalAnnotations(text);
141
+ if (board === null) return false;
142
+ return writeReplica(doc, parseBoard(board).elements, origin, {
143
+ ...(validActionId(actionId) ? { actionId } : {}),
144
+ });
134
145
  }
135
146
 
136
147
  export function createRegistry(callbacks: RoomCallbacks): Registry {
@@ -218,10 +229,35 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
218
229
  applyCommentsToDoc(room.doc, comments as unknown[], 'inspector-write');
219
230
  }
220
231
 
221
- function syncRoomFromAnnotations(slug: string, svg: string, writeId?: string): void {
232
+ function syncRoomFromAnnotations(slug: string, text: string, actionId?: string): void {
222
233
  const room = rooms.get(slug);
223
234
  if (!room) return;
224
- applyAnnotationsToDoc(room.doc, svg, 'inspector-write', writeId);
235
+ applyAnnotationsToDoc(room.doc, text, 'inspector-write', actionId);
236
+ }
237
+
238
+ function applyOpsToRoom(
239
+ slug: string,
240
+ ops: readonly Op[],
241
+ actionId?: string
242
+ ): ApplyResult | 'too-large' | null {
243
+ const room = rooms.get(slug);
244
+ // A replica that was never populated is not the board's truth — the
245
+ // caller takes the disk path instead (DDR-223: no value ≠ empty).
246
+ const cur = room ? readReplica(room.doc) : null;
247
+ if (!room || cur === null) return null;
248
+ const r = applyOps(new Map(cur.elements.map((e) => [e.id, e])), ops);
249
+ if (!r.touched.size) return r;
250
+ const next = [...r.state.values()];
251
+ if (serializeBoard(next).length > MAX_ANNOTATIONS_BYTES) return 'too-large';
252
+ // One transaction with the lane's newest-wins stamp, so peers get ONE
253
+ // update and the room's persistence projects it to disk.
254
+ room.doc.transact(() => {
255
+ writeReplica(room.doc, next, 'inspector-write', {
256
+ ...(validActionId(actionId) ? { actionId } : {}),
257
+ });
258
+ stampAnnotationsEdit(room.doc, 'inspector-write');
259
+ }, 'inspector-write');
260
+ return r;
225
261
  }
226
262
 
227
263
  function setAgentEditing(slug: string, state: { name: string; since: number } | null): void {
@@ -275,6 +311,7 @@ export function createRegistry(callbacks: RoomCallbacks): Registry {
275
311
  getDoc,
276
312
  syncRoomFromComments,
277
313
  syncRoomFromAnnotations,
314
+ applyOpsToRoom,
278
315
  setAgentEditing,
279
316
  attachHubAwareness,
280
317
  pin,