@1agh/maude 1.4.6 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -25,6 +25,8 @@
25
25
  * - sticky / polygon / image / link — see the per-tool serializers below.
26
26
  */
27
27
 
28
+ import { defOf } from './annotations/registry.ts';
29
+ import type { AnnotationElement, GeomCtx } from './annotations/types.ts';
28
30
  import {
29
31
  ARROW_HEADS,
30
32
  type ArrowHead,
@@ -318,7 +320,21 @@ export interface SectionStroke extends StrokeBase {
318
320
  color: string;
319
321
  }
320
322
 
323
+ /**
324
+ * DDR-242 (Task 25) — an element of a registered type with no stroke form of
325
+ * its own. Its world-space record rides along and every geometry question is
326
+ * answered by its registry definition, so a new element type is one model file:
327
+ * the whiteboard draws, selects, moves, syncs and hands it to the AI verbs
328
+ * without another `tool ===` branch.
329
+ */
330
+ export interface ElementStroke extends StrokeBase {
331
+ tool: 'element';
332
+ /** The element in WORLD coordinates, without `parent`. */
333
+ el: AnnotationElement;
334
+ }
335
+
321
336
  export type Stroke =
337
+ | ElementStroke
322
338
  | PenStroke
323
339
  | RectStroke
324
340
  | EllipseStroke
@@ -671,7 +687,7 @@ export function rid(): string {
671
687
  * and reload — and is what external references (comments' `annotationId`)
672
688
  * hold. A migration must carry ids over unchanged.
673
689
  */
674
- export function stableAnnotationId(el: Element, seen: Map<string, number>): string {
690
+ export function stableAnnotationId(el: SvgElLike, seen: Map<string, number>): string {
675
691
  const content = el.outerHTML;
676
692
  const n = seen.get(content) ?? 0;
677
693
  seen.set(content, n + 1);
@@ -1047,6 +1063,8 @@ export function strokeToSvgEl(s: Stroke): string {
1047
1063
  }
1048
1064
 
1049
1065
  function strokeToSvgElBase(s: Stroke): string {
1066
+ // No legacy SVG form: such an element only ever lives in a v2 board.
1067
+ if (s.tool === 'element') return '';
1050
1068
  if (s.tool === 'text') {
1051
1069
  // Phase 21 — anchored text keeps the byte-identical Phase 5.1 form;
1052
1070
  // standalone text (no anchorId) writes its own world x/y and omits
@@ -1348,7 +1366,7 @@ function parseFill(raw: string | null): string | null {
1348
1366
  * between them, so first-pair = start, last-pair = end recovers the ends
1349
1367
  * exactly → idempotent re-serialize).
1350
1368
  */
1351
- function arrowEndpoints(el: Element): { x1: number; y1: number; x2: number; y2: number } | null {
1369
+ function arrowEndpoints(el: SvgElLike): { x1: number; y1: number; x2: number; y2: number } | null {
1352
1370
  const line = el.querySelector('line');
1353
1371
  if (line) {
1354
1372
  return {
@@ -1421,7 +1439,7 @@ function sanitizeAuthorName(raw: string): string {
1421
1439
  }
1422
1440
 
1423
1441
  /** FigJam v3 — read the cross-tool root attrs back onto a parsed stroke. */
1424
- function readSharedAttrs(el: Element, s: Stroke): void {
1442
+ function readSharedAttrs(el: SvgElLike, s: Stroke): void {
1425
1443
  const g = el.getAttribute('data-group-ids');
1426
1444
  if (g) {
1427
1445
  const ids = g.split(/\s+/).filter(Boolean);
@@ -1447,12 +1465,39 @@ function readSharedAttrs(el: Element, s: Stroke): void {
1447
1465
  }
1448
1466
  }
1449
1467
 
1468
+ /**
1469
+ * The slice of the DOM `Element` API the parser reads. A browser `Element`
1470
+ * satisfies it; so does the DOM-free tree in `annotations/legacy/mini-dom.ts`,
1471
+ * which lets the hub (Node, no DOMParser) upconvert legacy SVG history for the
1472
+ * annotations-v2 migration (DDR-242 §6).
1473
+ */
1474
+ export interface SvgElLike {
1475
+ getAttribute(name: string): string | null;
1476
+ querySelector(selector: string): SvgElLike | null;
1477
+ querySelectorAll(selector: string): ArrayLike<SvgElLike>;
1478
+ readonly textContent: string | null;
1479
+ readonly outerHTML: string;
1480
+ }
1481
+
1482
+ export interface SvgDocLike {
1483
+ querySelector(selector: string): SvgElLike | null;
1484
+ querySelectorAll(selector: string): ArrayLike<SvgElLike>;
1485
+ }
1486
+
1450
1487
  export function svgToStrokes(svgText: string): Stroke[] {
1451
1488
  const text = (svgText ?? '').trim();
1452
1489
  if (!text) return [];
1453
1490
  if (typeof DOMParser === 'undefined') return [];
1454
1491
  try {
1455
- const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
1492
+ return strokesFromDocument(new DOMParser().parseFromString(text, 'image/svg+xml'));
1493
+ } catch {
1494
+ return [];
1495
+ }
1496
+ }
1497
+
1498
+ /** The parser proper, over any document exposing `SvgDocLike`. Never throws. */
1499
+ export function strokesFromDocument(doc: SvgDocLike): Stroke[] {
1500
+ try {
1456
1501
  if (doc.querySelector('parsererror')) return [];
1457
1502
  const out: Stroke[] = [];
1458
1503
  const seenContent = new Map<string, number>();
@@ -1773,6 +1818,7 @@ function pointSegmentDist(
1773
1818
 
1774
1819
  /** FigJam v3 — strokes whose `rotation` is honoured (see StrokeBase doc). */
1775
1820
  export function canRotate(s: Stroke): boolean {
1821
+ if (s.tool === 'element') return false;
1776
1822
  if (s.tool === 'pen' || s.tool === 'arrow' || s.tool === 'section') return false;
1777
1823
  if (s.tool === 'text') return s.anchorId == null || s.anchorId === '';
1778
1824
  return true;
@@ -1840,6 +1886,9 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
1840
1886
  wx >= bb.x - tol && wx <= bb.x + bb.w + tol && wy >= bb.y - tol && wy <= bb.y + bb.h + tol
1841
1887
  );
1842
1888
  }
1889
+ if (s.tool === 'element') {
1890
+ return defOf(s.el.type)?.hitTest(s.el, wx, wy, tol, WORLD_CTX) ?? false;
1891
+ }
1843
1892
  if (s.tool === 'section') {
1844
1893
  // FigJam — a section is grabbed by its BORDER or its label chip; the
1845
1894
  // interior stays click-through so content on the section selects normally.
@@ -2045,6 +2094,7 @@ export function normalizeSticky(s: StickyStroke): StickyStroke {
2045
2094
  }
2046
2095
 
2047
2096
  export function isStrokeMeaningful(s: Stroke): boolean {
2097
+ if (s.tool === 'element') return defOf(s.el.type)?.meaningful(s.el) ?? false;
2048
2098
  if (s.tool === 'pen') return s.points.length >= 2;
2049
2099
  if (s.tool === 'rect') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
2050
2100
  if (s.tool === 'polygon') return Math.abs(s.w) >= 4 && Math.abs(s.h) >= 4;
@@ -2063,10 +2113,17 @@ export function isStrokeMeaningful(s: Stroke): boolean {
2063
2113
  return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
2064
2114
  }
2065
2115
 
2116
+ const WORLD_CTX: GeomCtx = { origin: { x: 0, y: 0 }, resolve: () => null };
2117
+
2118
+ function elementBox(s: ElementStroke): { x: number; y: number; w: number; h: number } | null {
2119
+ return defOf(s.el.type)?.bounds(s.el, WORLD_CTX) ?? null;
2120
+ }
2121
+
2066
2122
  export function strokeBBox(
2067
2123
  s: Stroke,
2068
2124
  anchors?: Map<string, AnchorHost>
2069
2125
  ): { x: number; y: number; w: number; h: number } | null {
2126
+ if (s.tool === 'element') return elementBox(s);
2070
2127
  if (s.tool === 'pen') {
2071
2128
  if (!s.points.length) return null;
2072
2129
  let xMin = Number.POSITIVE_INFINITY;
@@ -2141,6 +2198,10 @@ export function strokeBBox(
2141
2198
  }
2142
2199
 
2143
2200
  export function translateOne(s: Stroke, dx: number, dy: number): Stroke {
2201
+ if (s.tool === 'element') {
2202
+ const def = defOf(s.el.type);
2203
+ return def ? { ...s, el: { ...s.el, ...def.translate(s.el, dx, dy) } } : s;
2204
+ }
2144
2205
  if (s.tool === 'pen') {
2145
2206
  return { ...s, points: s.points.map(([x, y]) => [x + dx, y + dy] as WorldPoint) };
2146
2207
  }
@@ -1,50 +1,7 @@
1
- import type * as Y from 'yjs';
2
-
3
- /** Echo identity is transport metadata, never authorization or a durable ACK. */
4
- export const ANNOTATION_WRITE_ID = 'writeId';
5
-
1
+ /**
2
+ * Write/action ids travel with annotation writes as echo metadata (DDR-242:
3
+ * the replica's `~action`), never as authorization or a durable ACK.
4
+ */
6
5
  export function validAnnotationWriteId(value: unknown): value is string {
7
6
  return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,96}$/.test(value);
8
7
  }
9
-
10
- /** Remember authored operations, not previously rendered content (A → B → A). */
11
- export function createAnnotationEchoGuard() {
12
- const authored = new Map<string, string>();
13
- return {
14
- remember(id: string, svg: string) {
15
- authored.set(id, svg);
16
- if (authored.size > 64) {
17
- const oldest = authored.keys().next().value;
18
- if (oldest !== undefined) authored.delete(oldest);
19
- }
20
- },
21
- forget(id: string) {
22
- authored.delete(id);
23
- },
24
- isOwn(svg: string, id: unknown) {
25
- return validAnnotationWriteId(id) && authored.get(id) === svg;
26
- },
27
- };
28
- }
29
-
30
- /** Old clients/importers may change svg without changing its old writeId. */
31
- export function observeAnnotationSnapshots(
32
- doc: Y.Doc,
33
- receive: (svg: string, writeId: unknown) => void
34
- ): () => void {
35
- const map = doc.getMap<unknown>('annotations');
36
- const apply = (event?: Y.YMapEvent<unknown>) => {
37
- if (event && !event.keysChanged.has('svg') && !event.keysChanged.has(ANNOTATION_WRITE_ID))
38
- return;
39
- const svg = map.get('svg');
40
- if (typeof svg !== 'string' && !event?.keysChanged.has('svg')) return;
41
- const id =
42
- !event || event.keysChanged.has(ANNOTATION_WRITE_ID)
43
- ? map.get(ANNOTATION_WRITE_ID)
44
- : undefined;
45
- receive(typeof svg === 'string' ? svg : '', id);
46
- };
47
- map.observe(apply);
48
- apply();
49
- return () => map.unobserve(apply);
50
- }
@@ -14,6 +14,16 @@ 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 './annotations/ops-merge.ts';
25
+ import { parseBoard, serializeBoard } from './annotations/schema.ts';
26
+ import type { AnnotationElement } from './annotations/types.ts';
17
27
  import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
18
28
  import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifacts.ts';
19
29
  import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
@@ -23,6 +33,7 @@ import { isAnnotationId, isWorldPoint } from './comment-anchor.ts';
23
33
  import { atomicWrite } from './sync/atomic-write.ts';
24
34
  import { dedupeCommentsById } from './sync/comment-identity.ts';
25
35
  import { isRuntimeStateRel } from './sync/file-membership.ts';
36
+ import { MAX_ANNOTATIONS_BYTES } from './sync/limits.ts';
26
37
 
27
38
  // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
28
39
  // importing it from api.ts — the actual implementation now lives in
@@ -34,7 +45,7 @@ export { canvasSlugFromRel } from './canvas-slug.ts';
34
45
  * delete: what it previews (notes, styles, data, images, media, fonts), minus
35
46
  * the canvas's own sidecars, which only ever travel with their canvas. */
36
47
  export function isSupportingFileRel(rel: string): boolean {
37
- if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
48
+ if (/\.(meta\.json|annotations\.(?:svg|json)|registry\.json)$/i.test(rel)) return false;
38
49
  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(
39
50
  rel
40
51
  );
@@ -117,6 +128,7 @@ import { STICKERS_DIR } from './paths.ts';
117
128
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
118
129
  import { sessionDir } from './session-scope.ts';
119
130
  import { describeSourceOp } from './sync/source-ops.ts';
131
+ import { normalizeTreeState, type TreeState } from './tree-state.ts';
120
132
  import { isWorkspaceMode } from './workspace-mode.ts';
121
133
 
122
134
  // Directories that never hold user-facing canvases. Exported so the
@@ -324,7 +336,7 @@ export interface Comment {
324
336
  * text is untrusted user/peer text (DDR-054) — rendered as text, never
325
337
  * into TSX. */
326
338
  timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
327
- /** #134/#136 — anchor on an annotation (`data-id` in `*.annotations.svg`).
339
+ /** #134/#136 — anchor on an annotation (an element `id` in `*.annotations.json`).
328
340
  * Absent on element and floating comments. See comment-anchor.ts. */
329
341
  annotationId?: string;
330
342
  /** World point of the comment: the anchor of a floating comment, the last
@@ -424,6 +436,8 @@ export interface Api {
424
436
  // Canvas state
425
437
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
426
438
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
439
+ loadTreeState(): Promise<TreeState>;
440
+ saveTreeState(state: TreeState): Promise<TreeState>;
427
441
  timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
428
442
  timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
429
443
  // Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
@@ -436,11 +450,23 @@ export interface Api {
436
450
  file: string,
437
451
  patch: Record<string, unknown>
438
452
  ): Promise<Record<string, unknown> | null>;
439
- // Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
453
+ // Annotations board (DDR-242 — .design/<slug>.annotations.json)
454
+ /** Canonical board text; a not-yet-migrated legacy SVG is read through the migration. */
440
455
  loadAnnotations(file: string): Promise<string | null>;
441
- saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
456
+ /** Strict read: an existing board that can't be read is `ok: false`, never an empty board. */
457
+ readBoard(
458
+ file: string
459
+ ): Promise<{ ok: true; text: string | null } | { ok: false; error: string }>;
460
+ /** Whole-board write (board text or legacy SVG, canonicalized). */
461
+ saveAnnotations(file: string, text: string, writeId?: string, base?: string): Promise<boolean>;
462
+ /** The canvas write path: an op batch under the DDR-242 merge rule. */
463
+ applyAnnotationOps(
464
+ file: string,
465
+ ops: readonly AnnotationOp[],
466
+ actionId?: string
467
+ ): Promise<AnnotationOpsResult>;
442
468
  /** Materialize a document snapshot without publishing it as another user edit. */
443
- projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
469
+ projectAnnotations(file: string, text: string, isCurrent: () => boolean): Promise<boolean>;
444
470
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
445
471
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
446
472
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
@@ -818,8 +844,22 @@ export interface ApiHooks {
818
844
  */
819
845
  /** `base` — the list this mutation started from (a merge hint for the project). */
820
846
  onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
821
- /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
822
- onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
847
+ /**
848
+ * Fires after every successful annotations write with the new canonical
849
+ * board text, the action/write id and the board text it replaced (a merge
850
+ * base for accepted revisions).
851
+ */
852
+ onAnnotationsChanged?: (file: string, text: string, writeId?: string, base?: string) => void;
853
+ /**
854
+ * Apply an op batch to the canvas's LIVE collab room, when one holds the
855
+ * board (code review H1). `null` = no live room / accepted mode — the batch
856
+ * goes to disk. The room's persistence projects the result to the file.
857
+ */
858
+ applyAnnotationOpsLive?: (
859
+ file: string,
860
+ ops: readonly AnnotationOp[],
861
+ actionId?: string
862
+ ) => ApplyResult | 'too-large' | null;
823
863
  /**
824
864
  * Accepted-revisions mode (DDR-241): propose a folder operation as ONE
825
865
  * project action before touching disk. Absent, or answering `null`, means
@@ -865,8 +905,18 @@ export interface ApiHooks {
865
905
  // server modules). Re-exported here so every existing `from './api.ts'`
866
906
  // import keeps working unchanged.
867
907
  export { ASSET_IMAGE_HREF_RE, sanitizeAnnotationSvg } from './annotations-model.ts';
868
-
869
- import { sanitizeAnnotationSvg } from './annotations-model.ts';
908
+ export type { AnnotationOp };
909
+ /** Outcome of an annotations op batch (DDR-242 §4). */
910
+ export interface AnnotationOpsResult {
911
+ ok: boolean;
912
+ /** Whether the board changed (false for a no-op batch). */
913
+ changed: boolean;
914
+ /** Ops the board could not take: `gone` (element deleted), `invalid`, `stale` (strict undo). */
915
+ rejected: Array<{ id?: string; reason: string; fields?: string[] }>;
916
+ error?: string;
917
+ /** The board on disk exists but can't be read — nothing was written (HTTP 409). */
918
+ unreadable?: boolean;
919
+ }
870
920
 
871
921
  /**
872
922
  * Phase 23 — per-file ceiling for a still image. Raised 10 MB → 50 MB (still
@@ -1574,6 +1624,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1574
1624
  }
1575
1625
  }
1576
1626
 
1627
+ // ---------- File-tree expansion (issue #124) ----------
1628
+ //
1629
+ // Which folders + sections of the Files panel the user left open. Per-user
1630
+ // runtime state (DDR-115): it lives under `_canvas-state/` — already on every
1631
+ // ignore list, never versioned or synced — and goes through `sessionDir`, so
1632
+ // in a cell each member keeps their own tree. A SUBDIRECTORY, not a sibling
1633
+ // file: `/_canvas-state` builds `<fileSlug(file)>.json` from user input with
1634
+ // no host/origin guard, so any flat name here is reachable through it
1635
+ // (`?file=_file-tree` hit `_file-tree.json`). A slug never contains `/`.
1636
+ function treeStateDir(): string {
1637
+ return path.join(sessionDir(paths.canvasStateDir), '_tree');
1638
+ }
1639
+ function treeStatePath(): string {
1640
+ return path.join(treeStateDir(), 'state.json');
1641
+ }
1642
+
1643
+ async function loadTreeState(): Promise<TreeState> {
1644
+ try {
1645
+ return normalizeTreeState(JSON.parse(await Bun.file(treeStatePath()).text()));
1646
+ } catch {
1647
+ return { dirs: [], sections: {} };
1648
+ }
1649
+ }
1650
+
1651
+ async function saveTreeState(state: TreeState): Promise<TreeState> {
1652
+ const safe = normalizeTreeState(state);
1653
+ await mkdir(treeStateDir(), { recursive: true });
1654
+ await Bun.write(treeStatePath(), JSON.stringify(safe, null, 2));
1655
+ return safe;
1656
+ }
1657
+
1577
1658
  // ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
1578
1659
  //
1579
1660
  // Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
@@ -2079,81 +2160,179 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2079
2160
  return await loadCanvasMeta(file);
2080
2161
  }
2081
2162
 
2082
- // ---------- Annotations sidecar (Phase 5) ----------
2163
+ // ---------- Annotations sidecar (DDR-242 — annotations v2) ----------
2083
2164
  //
2084
- // Each canvas keeps a single `.annotations.svg` file under `<designRoot>/`
2085
- // named by the canonical `fileSlug()`. The client posts the full SVG string
2086
- // on every stroke commit; the server overwrites the file. SVG is bounded at
2087
- // 1 MB (rejects larger bodies) — well above realistic annotation sizes for
2088
- // hundreds of strokes but small enough that a malicious POST can't fill the
2089
- // disk in one round-trip.
2165
+ // Each canvas keeps ONE `<slug>.annotations.json` board under `<designRoot>/`
2166
+ // (canonical, one element per line — annotations/schema.ts). The canvas sends
2167
+ // OPS (`applyAnnotationOps`), never the whole board; whole-board writes
2168
+ // (`saveAnnotations`) remain for imports and headless writers. Every write is
2169
+ // validated element-by-element (DDR-054 — the canvas origin and peers are
2170
+ // untrusted) and capped at MAX_ANNOTATIONS_BYTES.
2171
+ //
2172
+ // A board still stored as a legacy `.annotations.svg` (not yet migrated) is
2173
+ // READ through the v1→v2 migration; the boot migration (annotations/
2174
+ // migrate-boot.ts) rewrites it on disk.
2090
2175
 
2091
2176
  function annotationsPath(file: string): string {
2177
+ return path.join(paths.designRoot, `${fileSlug(file)}.annotations.json`);
2178
+ }
2179
+
2180
+ function legacyAnnotationsPath(file: string): string {
2092
2181
  return path.join(paths.designRoot, `${fileSlug(file)}.annotations.svg`);
2093
2182
  }
2094
2183
 
2095
- async function loadAnnotations(file: string): Promise<string | null> {
2096
- try {
2097
- return await Bun.file(annotationsPath(file)).text();
2098
- } catch {
2099
- return null;
2184
+ type BoardRead = { ok: true; text: string | null } | { ok: false; error: string };
2185
+
2186
+ /**
2187
+ * Strict board read. `text: null` = the canvas has no annotations. A file
2188
+ * that EXISTS but is oversized or not a board is `ok: false` — never an
2189
+ * empty board, or the next write would silently erase it (code review H2).
2190
+ */
2191
+ async function readBoard(file: string): Promise<BoardRead> {
2192
+ for (const p of [annotationsPath(file), legacyAnnotationsPath(file)]) {
2193
+ const f = Bun.file(p);
2194
+ if (!(await f.exists())) continue;
2195
+ // Untrusted (peer / git) content: size gate BEFORE reading (DDR-054).
2196
+ if (f.size > MAX_ANNOTATIONS_BYTES) {
2197
+ return { ok: false, error: 'board file exceeds the size cap' };
2198
+ }
2199
+ let raw: string;
2200
+ try {
2201
+ raw = await f.text();
2202
+ } catch {
2203
+ return { ok: false, error: 'board file is unreadable' };
2204
+ }
2205
+ const text = canonicalAnnotations(raw);
2206
+ return text === null
2207
+ ? { ok: false, error: 'board file is not a valid board' }
2208
+ : { ok: true, text };
2100
2209
  }
2210
+ return { ok: true, text: null };
2211
+ }
2212
+
2213
+ /** Canonical board text, or null when the canvas has no annotations (or they can't be read). */
2214
+ async function loadAnnotations(file: string): Promise<string | null> {
2215
+ const r = await readBoard(file);
2216
+ return r.ok ? r.text : null;
2217
+ }
2218
+
2219
+ // Read-modify-write of one board is serialized per file: two op batches
2220
+ // landing together must not both read the same state and drop one another.
2221
+ const annotationChains = new Map<string, Promise<unknown>>();
2222
+ function onAnnotationChain<T>(file: string, fn: () => Promise<T>): Promise<T> {
2223
+ const key = fileSlug(file);
2224
+ const prev = annotationChains.get(key) ?? Promise.resolve();
2225
+ const next = prev.then(fn, fn);
2226
+ annotationChains.set(
2227
+ key,
2228
+ next.catch(() => {})
2229
+ );
2230
+ return next;
2231
+ }
2232
+
2233
+ async function writeBoardFile(file: string, text: string): Promise<void> {
2234
+ await Bun.write(annotationsPath(file), text);
2235
+ // Annotations reach OTHER VIEWERS over the collab room, but the file is also
2236
+ // a versioned, file-plane sidecar (DDR-115), and the file plane learns about
2237
+ // a cell's own writes through exactly this event.
2238
+ announceWritten(`${fileSlug(file)}.annotations.json`);
2101
2239
  }
2102
2240
 
2241
+ /**
2242
+ * Whole-board write (imports, headless writers, legacy PUT). Accepts board
2243
+ * text or a legacy SVG (converted). `base` travels only as a merge hint for
2244
+ * the project — bounded and canonicalized like the value, never written.
2245
+ */
2103
2246
  async function saveAnnotations(
2104
2247
  file: string,
2105
- svg: string,
2248
+ text: string,
2106
2249
  writeId?: string,
2107
2250
  base?: string
2108
2251
  ): Promise<boolean> {
2109
- if (typeof svg !== 'string') return false;
2110
- if (svg.length > 1024 * 1024) return false;
2111
- // Cheap content gate — must look like an <svg> document. Avoids accidental
2112
- // writes of arbitrary blobs through this endpoint.
2113
- if (!/^\s*<svg[\s>]/i.test(svg)) return false;
2114
- // A3 (DDR-060 F1 re-audit) — sanitize active content before persisting.
2115
- // This endpoint is on the canvas-origin allowlist (DDR-054 "inert collab
2116
- // write") and accepts ANY `file`, so a hub-pushed canvas can write a
2117
- // sibling's `.annotations.svg`. The persisted SVG is currently consumed only
2118
- // via `svgToStrokes` (DOMParser image/svg+xml → structured strokes → React
2119
- // re-render), so a `<script>`/`on*` payload is parsed inertly and discarded
2120
- // — the stored-XSS chain is LATENT today, not live. We sanitize anyway so
2121
- // "inert" stays true for any future raw-render consumer and for the synced
2122
- // file a peer/Claude-context ingests. The legit annotation vocabulary
2123
- // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
2124
- // polyline/text — so stripping executable constructs is zero-regression.
2125
- const clean = sanitizeAnnotationSvg(svg);
2126
- // The edit's base travels only as a merge hint for the project — bounded
2127
- // and sanitized like the value itself, never written anywhere.
2252
+ if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
2253
+ const clean = canonicalAnnotations(text);
2254
+ if (clean === null) return false;
2128
2255
  const cleanBase =
2129
- typeof base === 'string' &&
2130
- base.length <= 1024 * 1024 &&
2131
- (base === '' || /^\s*<svg[\s>]/i.test(base))
2132
- ? base === ''
2133
- ? ''
2134
- : sanitizeAnnotationSvg(base)
2256
+ typeof base === 'string' && base.length <= MAX_ANNOTATIONS_BYTES
2257
+ ? (canonicalAnnotations(base) ?? undefined)
2135
2258
  : undefined;
2136
- await Bun.write(annotationsPath(file), clean);
2137
- onAnnotationsChanged?.(file, clean, writeId, cleanBase);
2138
- // Annotations reach OTHER VIEWERS over the collab room, which is why this
2139
- // never needed an `fs:any`. But the file is also a versioned, file-plane
2140
- // sidecar (DDR-115), and the file plane learns about a cell's own writes
2141
- // through exactly this event — so without it, a sticky note drawn in the
2142
- // cloud crossed to open browsers instantly and to a peer's DISK a quarter
2143
- // of an hour later.
2144
- announceWritten(`${fileSlug(file)}.annotations.svg`);
2145
- return true;
2259
+ return onAnnotationChain(file, async () => {
2260
+ // Never overwrite a board we can't read — it may hold work (review H2).
2261
+ const read = await readBoard(file);
2262
+ if (!read.ok) return false;
2263
+ let next = clean;
2264
+ if (cleanBase !== undefined) {
2265
+ // With a base, the write is "what changed since base", merged onto the
2266
+ // current board — a whole-board PUT no longer erases concurrent edits
2267
+ // (code review M5). Same one merge rule as the op path.
2268
+ const byId = (t: string) => new Map(parseBoard(t).elements.map((e) => [e.id, e]));
2269
+ const ops = diffToOps(byId(cleanBase), byId(clean));
2270
+ const live = hooks.applyAnnotationOpsLive?.(file, ops, writeId) ?? null;
2271
+ if (live === 'too-large') return false;
2272
+ if (live) return true;
2273
+ const current = read.text ?? serializeBoard([]);
2274
+ next = serializeBoard([...applyAnnotationOpsPure(byId(current), ops).state.values()]);
2275
+ if (next.length > MAX_ANNOTATIONS_BYTES) return false;
2276
+ if (next === current) return true;
2277
+ }
2278
+ await writeBoardFile(file, next);
2279
+ onAnnotationsChanged?.(file, next, writeId, cleanBase);
2280
+ return true;
2281
+ });
2282
+ }
2283
+
2284
+ /**
2285
+ * The canvas write path (DDR-242 §4): apply an op batch under the one merge
2286
+ * rule and persist the result. Ops the board can't take (gone / invalid /
2287
+ * stale) are reported, never silently dropped.
2288
+ */
2289
+ async function applyAnnotationOps(
2290
+ file: string,
2291
+ ops: readonly AnnotationOp[],
2292
+ actionId?: string
2293
+ ): Promise<AnnotationOpsResult> {
2294
+ return onAnnotationChain(file, async () => {
2295
+ const rejectedOf = (r: ApplyResult) =>
2296
+ r.rejected.map((x) => ({
2297
+ id: 'id' in x.op ? x.op.id : x.op.el?.id,
2298
+ reason: x.reason,
2299
+ ...(x.fields ? { fields: x.fields } : {}),
2300
+ }));
2301
+ // A live room is ahead of the file (its flush is debounced): the batch
2302
+ // goes to its replica, or it would revert a peer's unflushed edit (H1).
2303
+ const live = hooks.applyAnnotationOpsLive?.(file, ops, actionId) ?? null;
2304
+ if (live === 'too-large') {
2305
+ return { ok: false, changed: false, rejected: [], error: 'board exceeds the size cap' };
2306
+ }
2307
+ if (live) return { ok: true, changed: live.touched.size > 0, rejected: rejectedOf(live) };
2308
+ const read = await readBoard(file);
2309
+ if (!read.ok) {
2310
+ return { ok: false, changed: false, rejected: [], error: read.error, unreadable: true };
2311
+ }
2312
+ const before = read.text ? parseBoard(read.text).elements : [];
2313
+ const beforeText = serializeBoard(before);
2314
+ const r = applyAnnotationOpsPure(new Map(before.map((e) => [e.id, e])), ops);
2315
+ const rejected = rejectedOf(r);
2316
+ if (!r.touched.size) return { ok: true, changed: false, rejected };
2317
+ const text = serializeBoard([...r.state.values()]);
2318
+ if (text.length > MAX_ANNOTATIONS_BYTES) {
2319
+ return { ok: false, changed: false, rejected, error: 'board exceeds the size cap' };
2320
+ }
2321
+ await writeBoardFile(file, text);
2322
+ onAnnotationsChanged?.(file, text, actionId, beforeText);
2323
+ return { ok: true, changed: true, rejected };
2324
+ });
2146
2325
  }
2147
2326
 
2148
2327
  async function projectAnnotations(
2149
2328
  file: string,
2150
- svg: string,
2329
+ text: string,
2151
2330
  isCurrent: () => boolean
2152
2331
  ): Promise<boolean> {
2153
- if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
2154
- return false;
2332
+ if (typeof text !== 'string' || text.length > MAX_ANNOTATIONS_BYTES) return false;
2155
2333
  if (!isCurrent()) return false;
2156
- const clean = sanitizeAnnotationSvg(svg);
2334
+ const clean = canonicalAnnotations(text);
2335
+ if (clean === null) return false;
2157
2336
  // Runtime scratch stays out of the file plane. The async IO must not touch
2158
2337
  // the serving file until we recheck the document; another edit may have
2159
2338
  // arrived while Bun.write was pending. Check + rename have no await gap.
@@ -2164,7 +2343,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2164
2343
  await Bun.write(temp, clean);
2165
2344
  if (!isCurrent()) return false;
2166
2345
  renameSync(temp, annotationsPath(file));
2167
- announceWritten(`${fileSlug(file)}.annotations.svg`);
2346
+ announceWritten(`${fileSlug(file)}.annotations.json`);
2168
2347
  return true;
2169
2348
  } finally {
2170
2349
  await rm(temp, { force: true });
@@ -2982,12 +3161,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2982
3161
  }
2983
3162
  const slug = fileSlug(toRel);
2984
3163
  // The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
2985
- const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
2986
- if (await Bun.file(annotationsAbs).exists()) {
2987
- await Bun.write(
2988
- path.join(paths.designRoot, `${slug}.annotations.svg`),
2989
- await Bun.file(annotationsAbs).arrayBuffer()
2990
- );
3164
+ const board = await loadAnnotations(rel);
3165
+ if (board !== null) {
3166
+ await Bun.write(path.join(paths.designRoot, `${slug}.annotations.json`), board);
2991
3167
  }
2992
3168
  ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
2993
3169
  ctx.bus.emit('canvas-created', { slug });
@@ -3192,7 +3368,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3192
3368
  if (e.isDirectory()) {
3193
3369
  const hit = await walk(abs, depth + 1);
3194
3370
  if (hit) return hit;
3195
- } else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
3371
+ } else if (/\.(tsx|jsx|css|meta\.json|annotations\.(?:svg|json))$/i.test(e.name)) {
3196
3372
  const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
3197
3373
  if (other === rel) continue;
3198
3374
  const text = await readFile(abs, 'utf8').catch(() => '');
@@ -6621,13 +6797,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
6621
6797
  parseMentions,
6622
6798
  loadCanvasState,
6623
6799
  saveCanvasState,
6800
+ loadTreeState,
6801
+ saveTreeState,
6624
6802
  timelineMediaLoad,
6625
6803
  timelineMediaSave,
6626
6804
  loadCanvasMeta,
6627
6805
  loadCanvasSource,
6628
6806
  patchCanvasMeta,
6629
6807
  loadAnnotations,
6808
+ readBoard,
6630
6809
  saveAnnotations,
6810
+ applyAnnotationOps,
6631
6811
  projectAnnotations,
6632
6812
  saveAsset,
6633
6813
  listAssets,