@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
@@ -14,14 +14,25 @@ import {
14
14
  stat as statp,
15
15
  } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
+ import { canonicalAnnotations } from './annotations/board-text.ts';
18
+ import {
19
+ type Op as AnnotationOp,
20
+ type ApplyResult,
21
+ applyOps as applyAnnotationOpsPure,
22
+ diffToOps,
23
+ } from './annotations/ops.ts';
24
+ import { parseBoard, serializeBoard } from './annotations/schema.ts';
25
+ import type { AnnotationElement } from './annotations/types.ts';
17
26
  import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
18
27
  import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
19
28
  import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
20
29
  import { rewriteRelativeImports } from './canvas-imports.ts';
21
30
  import { canvasSlugFromRel } from './canvas-slug.ts';
31
+ import { isAnnotationId, isWorldPoint } from './comment-anchor.ts';
22
32
  import { atomicWrite } from './sync/atomic-write.ts';
23
33
  import { dedupeCommentsById } from './sync/comment-identity.ts';
24
34
  import { isRuntimeStateRel } from './sync/file-membership.ts';
35
+ import { MAX_ANNOTATIONS_BYTES } from './sync/limits.ts';
25
36
 
26
37
  // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
27
38
  // importing it from api.ts — the actual implementation now lives in
@@ -33,7 +44,7 @@ export { canvasSlugFromRel } from './canvas-slug.ts';
33
44
  * delete: what it previews (notes, styles, data, images, media, fonts), minus
34
45
  * the canvas's own sidecars, which only ever travel with their canvas. */
35
46
  export function isSupportingFileRel(rel: string): boolean {
36
- if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
47
+ if (/\.(meta\.json|annotations\.(?:svg|json)|registry\.json)$/i.test(rel)) return false;
37
48
  return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test(
38
49
  rel
39
50
  );
@@ -116,6 +127,7 @@ import { STICKERS_DIR } from './paths.ts';
116
127
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
117
128
  import { sessionDir } from './session-scope.ts';
118
129
  import { describeSourceOp } from './sync/source-ops.ts';
130
+ import { normalizeTreeState, type TreeState } from './tree-state.ts';
119
131
  import { isWorkspaceMode } from './workspace-mode.ts';
120
132
 
121
133
  // Directories that never hold user-facing canvases. Exported so the
@@ -323,6 +335,12 @@ export interface Comment {
323
335
  * text is untrusted user/peer text (DDR-054) — rendered as text, never
324
336
  * into TSX. */
325
337
  timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
338
+ /** #134/#136 — anchor on an annotation (an element `id` in `*.annotations.json`).
339
+ * Absent on element and floating comments. See comment-anchor.ts. */
340
+ annotationId?: string;
341
+ /** World point of the comment: the anchor of a floating comment, the last
342
+ * known place of an anchored one. Absent on legacy comments. */
343
+ world?: { x: number; y: number };
326
344
  }
327
345
 
328
346
  export interface GitCommitter {
@@ -417,6 +435,8 @@ export interface Api {
417
435
  // Canvas state
418
436
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
419
437
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
438
+ loadTreeState(): Promise<TreeState>;
439
+ saveTreeState(state: TreeState): Promise<TreeState>;
420
440
  timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
421
441
  timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
422
442
  // Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
@@ -429,11 +449,23 @@ export interface Api {
429
449
  file: string,
430
450
  patch: Record<string, unknown>
431
451
  ): Promise<Record<string, unknown> | null>;
432
- // Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
452
+ // Annotations board (DDR-242 — .design/<slug>.annotations.json)
453
+ /** Canonical board text; a not-yet-migrated legacy SVG is read through the migration. */
433
454
  loadAnnotations(file: string): Promise<string | null>;
434
- saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
455
+ /** Strict read: an existing board that can't be read is `ok: false`, never an empty board. */
456
+ readBoard(
457
+ file: string
458
+ ): Promise<{ ok: true; text: string | null } | { ok: false; error: string }>;
459
+ /** Whole-board write (board text or legacy SVG, canonicalized). */
460
+ saveAnnotations(file: string, text: string, writeId?: string, base?: string): Promise<boolean>;
461
+ /** The canvas write path: an op batch under the DDR-242 merge rule. */
462
+ applyAnnotationOps(
463
+ file: string,
464
+ ops: readonly AnnotationOp[],
465
+ actionId?: string
466
+ ): Promise<AnnotationOpsResult>;
435
467
  /** Materialize a document snapshot without publishing it as another user edit. */
436
- projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
468
+ projectAnnotations(file: string, text: string, isCurrent: () => boolean): Promise<boolean>;
437
469
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
438
470
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
439
471
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
@@ -811,8 +843,22 @@ export interface ApiHooks {
811
843
  */
812
844
  /** `base` — the list this mutation started from (a merge hint for the project). */
813
845
  onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
814
- /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
815
- onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
846
+ /**
847
+ * Fires after every successful annotations write with the new canonical
848
+ * board text, the action/write id and the board text it replaced (a merge
849
+ * base for accepted revisions).
850
+ */
851
+ onAnnotationsChanged?: (file: string, text: string, writeId?: string, base?: string) => void;
852
+ /**
853
+ * Apply an op batch to the canvas's LIVE collab room, when one holds the
854
+ * board (code review H1). `null` = no live room / accepted mode — the batch
855
+ * goes to disk. The room's persistence projects the result to the file.
856
+ */
857
+ applyAnnotationOpsLive?: (
858
+ file: string,
859
+ ops: readonly AnnotationOp[],
860
+ actionId?: string
861
+ ) => ApplyResult | 'too-large' | null;
816
862
  /**
817
863
  * Accepted-revisions mode (DDR-241): propose a folder operation as ONE
818
864
  * project action before touching disk. Absent, or answering `null`, means
@@ -858,8 +904,18 @@ export interface ApiHooks {
858
904
  // server modules). Re-exported here so every existing `from './api.ts'`
859
905
  // import keeps working unchanged.
860
906
  export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
861
-
862
- import { sanitizeAnnotationSvg } from './annotations-model.ts';
907
+ export type { AnnotationOp };
908
+ /** Outcome of an annotations op batch (DDR-242 §4). */
909
+ export interface AnnotationOpsResult {
910
+ ok: boolean;
911
+ /** Whether the board changed (false for a no-op batch). */
912
+ changed: boolean;
913
+ /** Ops the board could not take: `gone` (element deleted), `invalid`, `stale` (strict undo). */
914
+ rejected: Array<{ id?: string; reason: string; fields?: string[] }>;
915
+ error?: string;
916
+ /** The board on disk exists but can't be read — nothing was written (HTTP 409). */
917
+ unreadable?: boolean;
918
+ }
863
919
 
864
920
  /**
865
921
  * Phase 23 — per-file ceiling for a still image. Raised 10 MB → 50 MB (still
@@ -1247,6 +1303,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1247
1303
  thread: Array.isArray(c.thread) ? c.thread : [],
1248
1304
  mentions: Array.isArray(c.mentions) ? c.mentions : [],
1249
1305
  ...(timeline ? { timeline } : { timeline: undefined }),
1306
+ // Same trust boundary for the #134/#136 anchors: a peer-synced comment
1307
+ // never passed commentsAdd, so an anchor that fails its shape is dropped
1308
+ // (the comment then renders from `bounds`, detached — never deleted).
1309
+ annotationId: isAnnotationId(c.annotationId) ? c.annotationId : undefined,
1310
+ world: isWorldPoint(c.world) ? { x: c.world.x, y: c.world.y } : undefined,
1250
1311
  };
1251
1312
  }
1252
1313
 
@@ -1462,6 +1523,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1462
1523
  }
1463
1524
  if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
1464
1525
  }
1526
+ // #134/#136 — annotation / world anchors (peer-supplied: shape-checked).
1527
+ if (isAnnotationId(payload.annotationId)) c.annotationId = payload.annotationId;
1528
+ if (isWorldPoint(payload.world)) c.world = { x: payload.world.x, y: payload.world.y };
1465
1529
  list.push(c);
1466
1530
  await publishComments(payload.file, list, base);
1467
1531
  return c;
@@ -1559,6 +1623,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1559
1623
  }
1560
1624
  }
1561
1625
 
1626
+ // ---------- File-tree expansion (issue #124) ----------
1627
+ //
1628
+ // Which folders + sections of the Files panel the user left open. Per-user
1629
+ // runtime state (DDR-115): it lives under `_canvas-state/` — already on every
1630
+ // ignore list, never versioned or synced — and goes through `sessionDir`, so
1631
+ // in a cell each member keeps their own tree. A SUBDIRECTORY, not a sibling
1632
+ // file: `/_canvas-state` builds `<fileSlug(file)>.json` from user input with
1633
+ // no host/origin guard, so any flat name here is reachable through it
1634
+ // (`?file=_file-tree` hit `_file-tree.json`). A slug never contains `/`.
1635
+ function treeStateDir(): string {
1636
+ return path.join(sessionDir(paths.canvasStateDir), '_tree');
1637
+ }
1638
+ function treeStatePath(): string {
1639
+ return path.join(treeStateDir(), 'state.json');
1640
+ }
1641
+
1642
+ async function loadTreeState(): Promise<TreeState> {
1643
+ try {
1644
+ return normalizeTreeState(JSON.parse(await Bun.file(treeStatePath()).text()));
1645
+ } catch {
1646
+ return { dirs: [], sections: {} };
1647
+ }
1648
+ }
1649
+
1650
+ async function saveTreeState(state: TreeState): Promise<TreeState> {
1651
+ const safe = normalizeTreeState(state);
1652
+ await mkdir(treeStateDir(), { recursive: true });
1653
+ await Bun.write(treeStatePath(), JSON.stringify(safe, null, 2));
1654
+ return safe;
1655
+ }
1656
+
1562
1657
  // ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
1563
1658
  //
1564
1659
  // Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
@@ -2064,81 +2159,179 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2064
2159
  return await loadCanvasMeta(file);
2065
2160
  }
2066
2161
 
2067
- // ---------- Annotations sidecar (Phase 5) ----------
2162
+ // ---------- Annotations sidecar (DDR-242 — annotations v2) ----------
2068
2163
  //
2069
- // Each canvas keeps a single `.annotations.svg` file under `<designRoot>/`
2070
- // named by the canonical `fileSlug()`. The client posts the full SVG string
2071
- // on every stroke commit; the server overwrites the file. SVG is bounded at
2072
- // 1 MB (rejects larger bodies) — well above realistic annotation sizes for
2073
- // hundreds of strokes but small enough that a malicious POST can't fill the
2074
- // disk in one round-trip.
2164
+ // Each canvas keeps ONE `<slug>.annotations.json` board under `<designRoot>/`
2165
+ // (canonical, one element per line — annotations/schema.ts). The canvas sends
2166
+ // OPS (`applyAnnotationOps`), never the whole board; whole-board writes
2167
+ // (`saveAnnotations`) remain for imports and headless writers. Every write is
2168
+ // validated element-by-element (DDR-054 — the canvas origin and peers are
2169
+ // untrusted) and capped at MAX_ANNOTATIONS_BYTES.
2170
+ //
2171
+ // A board still stored as a legacy `.annotations.svg` (not yet migrated) is
2172
+ // READ through the v1→v2 migration; the boot migration (annotations/
2173
+ // migrate-boot.ts) rewrites it on disk.
2075
2174
 
2076
2175
  function annotationsPath(file: string): string {
2176
+ return path.join(paths.designRoot, `${fileSlug(file)}.annotations.json`);
2177
+ }
2178
+
2179
+ function legacyAnnotationsPath(file: string): string {
2077
2180
  return path.join(paths.designRoot, `${fileSlug(file)}.annotations.svg`);
2078
2181
  }
2079
2182
 
2080
- async function loadAnnotations(file: string): Promise<string | null> {
2081
- try {
2082
- return await Bun.file(annotationsPath(file)).text();
2083
- } catch {
2084
- return null;
2183
+ type BoardRead = { ok: true; text: string | null } | { ok: false; error: string };
2184
+
2185
+ /**
2186
+ * Strict board read. `text: null` = the canvas has no annotations. A file
2187
+ * that EXISTS but is oversized or not a board is `ok: false` — never an
2188
+ * empty board, or the next write would silently erase it (code review H2).
2189
+ */
2190
+ async function readBoard(file: string): Promise<BoardRead> {
2191
+ for (const p of [annotationsPath(file), legacyAnnotationsPath(file)]) {
2192
+ const f = Bun.file(p);
2193
+ if (!(await f.exists())) continue;
2194
+ // Untrusted (peer / git) content: size gate BEFORE reading (DDR-054).
2195
+ if (f.size > MAX_ANNOTATIONS_BYTES) {
2196
+ return { ok: false, error: 'board file exceeds the size cap' };
2197
+ }
2198
+ let raw: string;
2199
+ try {
2200
+ raw = await f.text();
2201
+ } catch {
2202
+ return { ok: false, error: 'board file is unreadable' };
2203
+ }
2204
+ const text = canonicalAnnotations(raw);
2205
+ return text === null
2206
+ ? { ok: false, error: 'board file is not a valid board' }
2207
+ : { ok: true, text };
2085
2208
  }
2209
+ return { ok: true, text: null };
2210
+ }
2211
+
2212
+ /** Canonical board text, or null when the canvas has no annotations (or they can't be read). */
2213
+ async function loadAnnotations(file: string): Promise<string | null> {
2214
+ const r = await readBoard(file);
2215
+ return r.ok ? r.text : null;
2216
+ }
2217
+
2218
+ // Read-modify-write of one board is serialized per file: two op batches
2219
+ // landing together must not both read the same state and drop one another.
2220
+ const annotationChains = new Map<string, Promise<unknown>>();
2221
+ function onAnnotationChain<T>(file: string, fn: () => Promise<T>): Promise<T> {
2222
+ const key = fileSlug(file);
2223
+ const prev = annotationChains.get(key) ?? Promise.resolve();
2224
+ const next = prev.then(fn, fn);
2225
+ annotationChains.set(
2226
+ key,
2227
+ next.catch(() => {})
2228
+ );
2229
+ return next;
2230
+ }
2231
+
2232
+ async function writeBoardFile(file: string, text: string): Promise<void> {
2233
+ await Bun.write(annotationsPath(file), text);
2234
+ // Annotations reach OTHER VIEWERS over the collab room, but the file is also
2235
+ // a versioned, file-plane sidecar (DDR-115), and the file plane learns about
2236
+ // a cell's own writes through exactly this event.
2237
+ announceWritten(`${fileSlug(file)}.annotations.json`);
2086
2238
  }
2087
2239
 
2240
+ /**
2241
+ * Whole-board write (imports, headless writers, legacy PUT). Accepts board
2242
+ * text or a legacy SVG (converted). `base` travels only as a merge hint for
2243
+ * the project — bounded and canonicalized like the value, never written.
2244
+ */
2088
2245
  async function saveAnnotations(
2089
2246
  file: string,
2090
- svg: string,
2247
+ text: string,
2091
2248
  writeId?: string,
2092
2249
  base?: string
2093
2250
  ): Promise<boolean> {
2094
- if (typeof svg !== 'string') return false;
2095
- if (svg.length > 1024 * 1024) return false;
2096
- // Cheap content gate — must look like an <svg> document. Avoids accidental
2097
- // writes of arbitrary blobs through this endpoint.
2098
- if (!/^\s*<svg[\s>]/i.test(svg)) return false;
2099
- // A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
2100
- // This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
2101
- // write") and accepts ANY `file`, so a hub-pushed canvas can write a
2102
- // sibling's `.annotations.svg`. The persisted SVG is currently consumed only
2103
- // via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
2104
- // re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
2105
- // — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
2106
- // "inert" stays true for any future raw-render consumer and for the synced
2107
- // file a peer/Claude-context ingests. The legit annotation vocabulary
2108
- // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
2109
- // polyline/text — so stripping executable constructs is zero-regression.
2110
- const clean = sanitizeAnnotationSvg(svg);
2111
- // The edit's base travels only as a merge hint for the project — bounded
2112
- // and sanitized like the value itself, never written anywhere.
2251
+ if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
2252
+ const clean = canonicalAnnotations(text);
2253
+ if (clean === null) return false;
2113
2254
  const cleanBase =
2114
- typeof base === 'string' &&
2115
- base.length <= 1024 * 1024 &&
2116
- (base === '' || /^\s*<svg[\s>]/i.test(base))
2117
- ? base === ''
2118
- ? ''
2119
- : sanitizeAnnotationSvg(base)
2255
+ typeof base === 'string' && base.length <= MAX_ANNOTATIONS_BYTES
2256
+ ? (canonicalAnnotations(base) ?? undefined)
2120
2257
  : undefined;
2121
- await Bun.write(annotationsPath(file), clean);
2122
- onAnnotationsChanged?.(file, clean, writeId, cleanBase);
2123
- // Annotations reach OTHER VIEWERS over the collab room, which is why this
2124
- // never needed an `fs:any`. But the file is also a versioned, file-plane
2125
- // sidecar (DDR-115), and the file plane learns about a cell's own writes
2126
- // through exactly this event — so without it, a sticky note drawn in the
2127
- // cloud crossed to open browsers instantly and to a peer's DISK a quarter
2128
- // of an hour later.
2129
- announceWritten(`${fileSlug(file)}.annotations.svg`);
2130
- return true;
2258
+ return onAnnotationChain(file, async () => {
2259
+ // Never overwrite a board we can't read — it may hold work (review H2).
2260
+ const read = await readBoard(file);
2261
+ if (!read.ok) return false;
2262
+ let next = clean;
2263
+ if (cleanBase !== undefined) {
2264
+ // With a base, the write is "what changed since base", merged onto the
2265
+ // current board — a whole-board PUT no longer erases concurrent edits
2266
+ // (code review M5). Same one merge rule as the op path.
2267
+ const byId = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
2268
+ const ops = diffToOps(byId(cleanBase), byId(clean));
2269
+ const live = hooks.applyAnnotationOpsLive?.(file, ops, writeId) ?? null;
2270
+ if (live === 'too-large') return false;
2271
+ if (live) return true;
2272
+ const current = read.text ?? serializeBoard([]);
2273
+ next = serializeBoard([...applyAnnotationOpsPure(byId(current), ops).state.values()]);
2274
+ if (next.length > MAX_ANNOTATIONS_BYTES) return false;
2275
+ if (next === current) return true;
2276
+ }
2277
+ await writeBoardFile(file, next);
2278
+ onAnnotationsChanged?.(file, next, writeId, cleanBase);
2279
+ return true;
2280
+ });
2281
+ }
2282
+
2283
+ /**
2284
+ * The canvas write path (DDR-242 §4): apply an op batch under the one merge
2285
+ * rule and persist the result. Ops the board can't take (gone / invalid /
2286
+ * stale) are reported, never silently dropped.
2287
+ */
2288
+ async function applyAnnotationOps(
2289
+ file: string,
2290
+ ops: readonly AnnotationOp[],
2291
+ actionId?: string
2292
+ ): Promise<AnnotationOpsResult> {
2293
+ return onAnnotationChain(file, async () => {
2294
+ const rejectedOf = (r: ApplyResult) =>
2295
+ r.rejected.map((x) => ({
2296
+ id: 'id' in x.op ? x.op.id : x.op.el?.id,
2297
+ reason: x.reason,
2298
+ ...(x.fields ? { fields: x.fields } : {}),
2299
+ }));
2300
+ // A live room is ahead of the file (its flush is debounced): the batch
2301
+ // goes to its replica, or it would revert a peer's unflushed edit (H1).
2302
+ const live = hooks.applyAnnotationOpsLive?.(file, ops, actionId) ?? null;
2303
+ if (live === 'too-large') {
2304
+ return { ok: false, changed: false, rejected: [], error: 'board exceeds the size cap' };
2305
+ }
2306
+ if (live) return { ok: true, changed: live.touched.size > 0, rejected: rejectedOf(live) };
2307
+ const read = await readBoard(file);
2308
+ if (!read.ok) {
2309
+ return { ok: false, changed: false, rejected: [], error: read.error, unreadable: true };
2310
+ }
2311
+ const before = read.text ? parseBoard(read.text).elements : [];
2312
+ const beforeText = serializeBoard(before);
2313
+ const r = applyAnnotationOpsPure(new Map(before.map((e) => [e.id, e])), ops);
2314
+ const rejected = rejectedOf(r);
2315
+ if (!r.touched.size) return { ok: true, changed: false, rejected };
2316
+ const text = serializeBoard([...r.state.values()]);
2317
+ if (text.length > MAX_ANNOTATIONS_BYTES) {
2318
+ return { ok: false, changed: false, rejected, error: 'board exceeds the size cap' };
2319
+ }
2320
+ await writeBoardFile(file, text);
2321
+ onAnnotationsChanged?.(file, text, actionId, beforeText);
2322
+ return { ok: true, changed: true, rejected };
2323
+ });
2131
2324
  }
2132
2325
 
2133
2326
  async function projectAnnotations(
2134
2327
  file: string,
2135
- svg: string,
2328
+ text: string,
2136
2329
  isCurrent: () => boolean
2137
2330
  ): Promise<boolean> {
2138
- if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
2139
- return false;
2331
+ if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
2140
2332
  if (!isCurrent()) return false;
2141
- const clean = sanitizeAnnotationSvg(svg);
2333
+ const clean = canonicalAnnotations(text);
2334
+ if (clean === null) return false;
2142
2335
  // Runtime scratch stays out of the file plane. The async IO must not touch
2143
2336
  // the serving file until we recheck the document; another edit may have
2144
2337
  // arrived while Bun.write was pending. Check + rename have no await gap.
@@ -2149,7 +2342,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2149
2342
  await Bun.write(temp, clean);
2150
2343
  if (!isCurrent()) return false;
2151
2344
  renameSync(temp, annotationsPath(file));
2152
- announceWritten(`${fileSlug(file)}.annotations.svg`);
2345
+ announceWritten(`${fileSlug(file)}.annotations.json`);
2153
2346
  return true;
2154
2347
  } finally {
2155
2348
  await rm(temp, { force: true });
@@ -2967,12 +3160,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2967
3160
  }
2968
3161
  const slug = fileSlug(toRel);
2969
3162
  // The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
2970
- const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
2971
- if (await Bun.file(annotationsAbs).exists()) {
2972
- await Bun.write(
2973
- path.join(paths.designRoot, `${slug}.annotations.svg`),
2974
- await Bun.file(annotationsAbs).arrayBuffer()
2975
- );
3163
+ const board = await loadAnnotations(rel);
3164
+ if (board !== null) {
3165
+ await Bun.write(path.join(paths.designRoot, `${slug}.annotations.json`), board);
2976
3166
  }
2977
3167
  ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
2978
3168
  ctx.bus.emit('canvas-created', { slug });
@@ -3177,7 +3367,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3177
3367
  if (e.isDirectory()) {
3178
3368
  const hit = await walk(abs, depth + 1);
3179
3369
  if (hit) return hit;
3180
- } else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
3370
+ } else if (/\.(tsx|jsx|css|meta\.json|annotations\.(?:svg|json))$/i.test(e.name)) {
3181
3371
  const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
3182
3372
  if (other === rel) continue;
3183
3373
  const text = await readFile(abs, 'utf8').catch(() => '');
@@ -6606,13 +6796,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
6606
6796
  parseMentions,
6607
6797
  loadCanvasState,
6608
6798
  saveCanvasState,
6799
+ loadTreeState,
6800
+ saveTreeState,
6609
6801
  timelineMediaLoad,
6610
6802
  timelineMediaSave,
6611
6803
  loadCanvasMeta,
6612
6804
  loadCanvasSource,
6613
6805
  patchCanvasMeta,
6614
6806
  loadAnnotations,
6807
+ readBoard,
6615
6808
  saveAnnotations,
6809
+ applyAnnotationOps,
6616
6810
  projectAnnotations,
6617
6811
  saveAsset,
6618
6812
  listAssets,
@@ -42,8 +42,16 @@ import {
42
42
  import { homedir, tmpdir } from 'node:os';
43
43
  import { join, resolve, sep } from 'node:path';
44
44
  import { pathToFileURL } from 'node:url';
45
+ import { serializeBoard } from '../annotations/schema.ts';
46
+
47
+ /**
48
+ * Translated strokes → canonical v2 board text (DDR-242): straight to
49
+ * registry-validated elements (`toBoardElements`), never through SVG.
50
+ */
51
+ function boardText(strokes) {
52
+ return serializeBoard(toBoardElements(strokes).elements);
53
+ }
45
54
 
46
- import { sanitizeAnnotationSvg, strokesToSvg } from '../annotations-model.ts';
47
55
  import {
48
56
  applyRewrites,
49
57
  FIGMA_ASSET_HOSTS,
@@ -68,7 +76,7 @@ import { readFigZip } from '../figma/fig-zip.ts';
68
76
  import { attrValue, ImportReport } from '../figma/sanitize.ts';
69
77
  import { JsxTooLargeError, toArtboard, toCanvas } from '../figma/to-artboard.ts';
70
78
  import { toRenderCanvas } from '../figma/to-render.ts';
71
- import { BoardTooLargeError, toStrokes } from '../figma/to-strokes.ts';
79
+ import { BoardTooLargeError, toBoardElements, toStrokes } from '../figma/to-strokes.ts';
72
80
  import { stylesToTokens, variablesToTokens } from '../figma/to-tokens.ts';
73
81
  import { FigmaCapError, normalizeDocument, walkNodes } from '../figma/types.ts';
74
82
  import { FigmaUrlError, parseFigmaTarget } from '../figma/url.ts';
@@ -258,9 +266,9 @@ export function formatSummary(report, extra = {}) {
258
266
  /**
259
267
  * Phase 2 — import a FigJam board into the whiteboard annotation layer.
260
268
  *
261
- * Writes `<designRoot>/<slug>.annotations.svg` through the CANONICAL serializer
262
- * plus `sanitizeAnnotationSvg`, so this verb can never persist a shape the
263
- * canvas would reject (D6's annotation row).
269
+ * Writes `<designRoot>/<slug>.annotations.json` as registry-validated v2
270
+ * elements (DDR-242), so this verb can never persist an element the canvas
271
+ * would reject (D6's annotation row).
264
272
  */
265
273
  /**
266
274
  * Read and decode a local `.fig` / `.jam` (DDR-221). Offline end to end: no
@@ -553,7 +561,9 @@ export async function importBoard({
553
561
  const usable = strokes.filter((s) => s.tool !== 'image' || Boolean(s.href));
554
562
  // Paper, then region, then content — in paint order. Either one emitted
555
563
  // after the board would veil it.
556
- const svgFinal = sanitizeAnnotationSvg(strokesToSvg([paper, backing, ...usable]));
564
+ // DDR-242 — the board is written as the v2 element model (`.annotations.json`):
565
+ // the translator's strokes become registry-validated elements directly.
566
+ const boardFinal = boardText([paper, backing, ...usable]);
557
567
 
558
568
  // The board needs a canvas to live on — see `boardHostCanvas`. The
559
569
  // annotation layer is named after THAT canvas's slug, not after a slug of
@@ -562,10 +572,10 @@ export async function importBoard({
562
572
  const canvasRel = `ui/${title}.tsx`;
563
573
  const annSlug = canvasSlug(canvasRel);
564
574
 
565
- const stagedSvg = join(staging, 'board.annotations.svg');
575
+ const stagedSvg = join(staging, 'board.annotations.json');
566
576
  const stagedTsx = join(staging, 'board.tsx');
567
577
  const stagedMeta = join(staging, 'board.meta.json');
568
- writeFileSync(stagedSvg, svgFinal, 'utf8');
578
+ writeFileSync(stagedSvg, boardFinal, 'utf8');
569
579
  writeFileSync(stagedTsx, boardHostCanvas(title), 'utf8');
570
580
  writeFileSync(
571
581
  stagedMeta,
@@ -583,7 +593,7 @@ export async function importBoard({
583
593
  const finalPath = assertContained(
584
594
  root,
585
595
  designRootRel,
586
- join(root, designRootRel, `${annSlug}.annotations.svg`)
596
+ join(root, designRootRel, `${annSlug}.annotations.json`)
587
597
  );
588
598
  const finalTsx = assertContained(root, designRootRel, join(root, designRootRel, canvasRel));
589
599
  const finalMeta = assertContained(
@@ -1099,12 +1109,12 @@ export async function importPages({
1099
1109
 
1100
1110
  if (annStrokes.length > 0) {
1101
1111
  const annSlug = canvasSlug(`${relDir}/${title}.tsx`);
1102
- const stagedAnn = join(staging, 'page.annotations.svg');
1103
- writeFileSync(stagedAnn, sanitizeAnnotationSvg(strokesToSvg(annStrokes)), 'utf8');
1112
+ const stagedAnn = join(staging, 'page.annotations.json');
1113
+ writeFileSync(stagedAnn, boardText(annStrokes), 'utf8');
1104
1114
  const finalAnn = assertContained(
1105
1115
  root,
1106
1116
  designRootRel,
1107
- join(root, designRootRel, `${annSlug}.annotations.svg`)
1117
+ join(root, designRootRel, `${annSlug}.annotations.json`)
1108
1118
  );
1109
1119
  promoteFile(stagedAnn, finalAnn);
1110
1120
  }