@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
@@ -391,3 +391,37 @@ export function resolvePulledTarget(args: {
391
391
  }
392
392
  return { bodyAbs, fromPath };
393
393
  }
394
+
395
+ /**
396
+ * The listing as an accepted-revisions project states it.
397
+ *
398
+ * In accepted mode the MANIFEST names the live canvases: an open Yjs room is
399
+ * not necessarily an accepted canvas (its author can connect before
400
+ * `doc.create` commits), so the documents come from the manifest, with the
401
+ * transport listing's byte counts where it has them.
402
+ *
403
+ * The same holds for DELETIONS. The transport's tombstones outlive the legacy
404
+ * era: a canvas deleted then and later re-created under the same name through
405
+ * `doc.create` stays tombstoned in the document store while the manifest lists
406
+ * it live — and every peer that honoured the tombstone skipped a live canvas
407
+ * (the web showed it, the desktop never pulled it). A tombstone for a name the
408
+ * manifest lists live is stale and dropped; one for a name the manifest
409
+ * retired, or does not carry, still stands. A peer's OWN just-made deletion is
410
+ * not affected: it is remembered locally and never passes through here.
411
+ */
412
+ export function acceptedListing(
413
+ listing: RemoteListing | null,
414
+ manifestDocs: readonly { doc: string; retired: boolean }[]
415
+ ): RemoteListing {
416
+ const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
417
+ const documents = manifestDocs
418
+ .filter((d) => !d.retired)
419
+ .map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
420
+ const live = new Set(
421
+ manifestDocs.filter((d) => !d.retired).map((d) => slugFromDocName(d.doc) ?? d.doc)
422
+ );
423
+ const tombstones = (listing?.tombstones ?? []).filter(
424
+ (t) => !live.has(slugFromDocName(t.name) ?? t.name)
425
+ );
426
+ return { documents, tombstones };
427
+ }
@@ -79,6 +79,15 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
79
79
  via: 'onAnnotationsChanged hook → annotations lane.replace with the base the layer sent',
80
80
  test: 'test/sync-accepted-projection.test.ts',
81
81
  },
82
+ // DDR-242 §4 — the canvas's op batches. Same lane, same hook: the api applies
83
+ // the ops to the board, then fires onAnnotationsChanged with the pre-batch
84
+ // board as base (the kernel's `annotations.apply` replaces this in Task 12).
85
+ '/_api/annotations/ops': {
86
+ class: 'lane',
87
+ rows: 'S23',
88
+ via: 'onAnnotationsChanged hook → annotations lane.replace with the pre-batch board as base',
89
+ test: 'test/sync-accepted-projection.test.ts',
90
+ },
82
91
  // --- accepted history (T27/T28)
83
92
  '/_api/project/history': { class: 'read' },
84
93
  '/_api/project/restore': {
@@ -230,6 +239,7 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
230
239
  '/_api/sync/settings': { class: 'local' },
231
240
  '/_api/sync/trash': { class: 'local', rows: 'X15 (restore re-enters through cold start)' },
232
241
  '/_api/timeline-media': { class: 'local' },
242
+ '/_api/tree-state': { class: 'local' },
233
243
  '/_api/ui-prefs': { class: 'local' },
234
244
  '/_api/workspace/disclosure': { class: 'local' },
235
245
  '/_api/workspace/sign-in': { class: 'local' },
@@ -146,7 +146,7 @@ interface CaretRect {
146
146
  * should hide (no collapsed selection inside the editable / editable not
147
147
  * focused / a range selection is active, where the native highlight is the
148
148
  * affordance). */
149
- function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
149
+ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | 'native' | null {
150
150
  const doc = editable.ownerDocument;
151
151
  const active = doc.activeElement;
152
152
  if (active !== editable && !editable.contains(active)) return null;
@@ -165,6 +165,13 @@ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
165
165
  if (br && br.height > 0) r = br;
166
166
  }
167
167
  if (r && r.height > 0) return { x: r.left, y: r.top, h: r.height };
168
+ // A caret on a line with no text box yet — Shift+Enter's "\n" puts it at
169
+ // the start of an empty last line — has no client rect in Chromium or
170
+ // WebKit. Guessing a position from the editable's box painted it back at
171
+ // the top-left of line ONE (the "Shift+Enter throws the cursor somewhere
172
+ // weird" report). Only the engine knows where that caret is, so hand the
173
+ // caret back to it: the native caret shows until a rect exists again.
174
+ if ((editable.textContent ?? '').replace(/\u200b/g, '') !== '') return 'native';
168
175
  // Empty editable — no rect exists anywhere. Derive a caret box from the
169
176
  // editable's padding box + line metrics. (The annotation editors avoid this
170
177
  // path by mounting with a zero-width-space JUMP_SENTINEL, but the artboard
@@ -221,7 +228,9 @@ export function mountCaret(editable: HTMLElement, win: Window): () => void {
221
228
 
222
229
  const position = (): void => {
223
230
  const rc = caretRectFor(editable, win);
224
- if (!rc) {
231
+ // The engine's own caret, while ours can't be placed (see caretRectFor).
232
+ editable.style.caretColor = rc === 'native' ? prevCaretColor || 'auto' : 'transparent';
233
+ if (!rc || rc === 'native') {
225
234
  if (shown) {
226
235
  caret.style.display = 'none';
227
236
  shown = false;
@@ -0,0 +1,45 @@
1
+ // tree-state.ts — the Files panel's remembered disclosure (issue #124).
2
+ //
3
+ // `dirs` are the repo-relative folder paths the user left expanded (the same
4
+ // `dirPath` the tree rows carry); `sections` maps a section label (PROJECT /
5
+ // UI CANVASES / …) to open/closed. Anything absent is CLOSED — that default is
6
+ // the point of the issue. Stored per project + per member by api.ts under
7
+ // `_canvas-state/`; this module only owns the shape and its bounds, so a
8
+ // hostile or corrupt body can neither grow the file without limit nor smuggle
9
+ // a non-string into a path the client later splits.
10
+
11
+ export interface TreeState {
12
+ dirs: string[];
13
+ sections: Record<string, boolean>;
14
+ }
15
+
16
+ export const TREE_STATE_MAX_DIRS = 2000;
17
+ export const TREE_STATE_MAX_PATH = 1024;
18
+ export const TREE_STATE_MAX_SECTIONS = 64;
19
+ const MAX_LABEL = 128;
20
+
21
+ /** Coerce anything into a bounded, de-duplicated TreeState. Never throws. */
22
+ export function normalizeTreeState(raw: unknown): TreeState {
23
+ const o =
24
+ raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
25
+ const dirs = new Set<string>();
26
+ if (Array.isArray(o.dirs)) {
27
+ for (const d of o.dirs) {
28
+ if (dirs.size >= TREE_STATE_MAX_DIRS) break;
29
+ if (typeof d !== 'string' || !d || d.length > TREE_STATE_MAX_PATH || d.includes('\0'))
30
+ continue;
31
+ dirs.add(d);
32
+ }
33
+ }
34
+ const sections: Record<string, boolean> = {};
35
+ if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
36
+ let n = 0;
37
+ for (const [k, v] of Object.entries(o.sections as Record<string, unknown>)) {
38
+ if (n >= TREE_STATE_MAX_SECTIONS) break;
39
+ if (typeof v !== 'boolean' || !k || k.length > MAX_LABEL || k === '__proto__') continue;
40
+ sections[k] = v;
41
+ n++;
42
+ }
43
+ }
44
+ return { dirs: [...dirs].sort(), sections };
45
+ }
@@ -69,8 +69,8 @@ export interface CommandRecord<P = unknown> {
69
69
  export interface CommandSinks {
70
70
  /** Wired by canvas-lib `DesignCanvasInner`. */
71
71
  layoutPatchFn?: unknown;
72
- /** Wired by `AnnotationsLayer`. */
73
- strokesPutFn?: unknown;
72
+ /** Wired by `AnnotationsLayer` (DDR-242): apply + send an annotation op batch. */
73
+ annotationOpsFn?: unknown;
74
74
  /**
75
75
  * Wired by `CanvasShell` (canvas-shell.tsx). Applies a single inline source
76
76
  * edit (CSS / text / attr) by posting `dgn:'apply-edit'` to the parent shell,
@@ -18,10 +18,13 @@
18
18
  */
19
19
 
20
20
  import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
21
+ import { defOf } from './annotations/registry.ts';
22
+ import { useAnnotationPipeline } from './annotations/ui/pipeline-context.ts';
21
23
  import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
22
24
  import {
23
25
  type ArrowStroke,
24
26
  canRotate,
27
+ type ElementStroke,
25
28
  type EllipseStroke,
26
29
  HALO_PAD_PX,
27
30
  type ImageStroke,
@@ -141,6 +144,7 @@ export const padDY = (c: Corner): number =>
141
144
  function isResizable(
142
145
  s: Stroke
143
146
  ): s is
147
+ | ElementStroke
144
148
  | RectStroke
145
149
  | EllipseStroke
146
150
  | PolygonStroke
@@ -150,6 +154,10 @@ function isResizable(
150
154
  | ImageStroke
151
155
  | LinkStroke
152
156
  | SectionStroke {
157
+ if (s.tool === 'element') {
158
+ const caps = defOf(s.el.type)?.caps;
159
+ return !!caps?.box && caps.resizable;
160
+ }
153
161
  return (
154
162
  s.tool === 'rect' ||
155
163
  s.tool === 'ellipse' ||
@@ -386,6 +394,15 @@ export function resizeStroke(
386
394
  const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
387
395
  return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
388
396
  }
397
+ if (start.tool === 'element') {
398
+ // A registry type: fit its box, the fields come from its definition.
399
+ const def = defOf(start.el.type);
400
+ const b0 = strokeBBox(start);
401
+ if (!def || !b0) return null;
402
+ const box = bboxResize(b0, corner, wx, wy, mods, !!def.caps.aspectLock);
403
+ const ctx = { origin: { x: 0, y: 0 }, resolve: () => null };
404
+ return { el: { ...start.el, ...def.resize(start.el, box, ctx) } } as Partial<Stroke>;
405
+ }
389
406
  if (start.tool === 'image') {
390
407
  // Phase 23 — images aspect-LOCK by default and free-resize with Shift held
391
408
  // (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
@@ -560,6 +577,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
560
577
  const annotSel = useAnnotationSelection();
561
578
  const controller = useViewportControllerContext();
562
579
  const vp = controller?.viewport ?? null;
580
+ const pipeline = useAnnotationPipeline();
563
581
  const containerRef = useRef<HTMLDivElement | null>(null);
564
582
  const rafRef = useRef<number | null>(null);
565
583
  const dragRef = useRef<{
@@ -725,6 +743,17 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
725
743
  } catch {
726
744
  /* some browsers reject capture on synthetic events */
727
745
  }
746
+ // The layer's pointer pipeline owns the moves and the release (Task 21);
747
+ // mounted without a layer (tests), the overlay listens itself.
748
+ if (pipeline) {
749
+ const pointerId = e.pointerId;
750
+ pipeline.begin(pointerId, {
751
+ kind: isRotCorner(corner) ? 'rotating' : 'resizing',
752
+ move: onMove,
753
+ up: onUp,
754
+ cancel: (ev) => onUp(ev ?? ({ pointerId } as PointerEvent)),
755
+ });
756
+ }
728
757
  };
729
758
  const applyResize = (
730
759
  clientX: number,
@@ -810,19 +839,15 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
810
839
  matchIds.push(bestH.id);
811
840
  }
812
841
  }
813
- document.dispatchEvent(
814
- new CustomEvent('maude:resize-info', {
815
- detail: {
816
- box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
817
- matchIds,
818
- },
819
- })
820
- );
842
+ store.setResizeInfo?.({
843
+ box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
844
+ matchIds,
845
+ });
821
846
  }
822
847
  // FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
823
848
  // magnet threshold of a bindable host the endpoint snaps + binds; free
824
849
  // space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
825
- // paints the candidate halo via the maude:bind-hint broadcast.
850
+ // paints the candidate halo (store.setBindHint).
826
851
  if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
827
852
  const zoom = vp?.zoom || 1;
828
853
  const cand = suppressBind
@@ -852,9 +877,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
852
877
  [d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
853
878
  } as Partial<Stroke>;
854
879
  }
855
- document.dispatchEvent(
856
- new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
857
- );
880
+ store.setBindHint?.(cand?.hostId ?? null);
858
881
  }
859
882
  // Wave H — transient preview tick: no undo record, no PUT. The gesture
860
883
  // commits ONCE on pointerup (undo used to walk every resize pixel).
@@ -898,26 +921,28 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
898
921
  );
899
922
  // Clear the bind-hint halo + size label regardless of what was dragged
900
923
  // (no-ops when nothing was hinted).
901
- document.dispatchEvent(new CustomEvent('maude:bind-hint', { detail: { hostId: null } }));
902
- document.dispatchEvent(
903
- new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
904
- );
924
+ store?.setBindHint?.(null);
925
+ store?.setResizeInfo?.(null);
905
926
  };
906
927
  c.addEventListener('pointerdown', onDown);
907
- document.addEventListener('pointermove', onMove);
908
- document.addEventListener('pointerup', onUp);
909
- document.addEventListener('pointercancel', onUp);
928
+ if (!pipeline) {
929
+ document.addEventListener('pointermove', onMove);
930
+ document.addEventListener('pointerup', onUp);
931
+ document.addEventListener('pointercancel', onUp);
932
+ }
910
933
  document.addEventListener('keydown', onKey, true);
911
934
  document.addEventListener('keyup', onKey, true);
912
935
  return () => {
913
936
  c.removeEventListener('pointerdown', onDown);
914
- document.removeEventListener('pointermove', onMove);
915
- document.removeEventListener('pointerup', onUp);
916
- document.removeEventListener('pointercancel', onUp);
937
+ if (!pipeline) {
938
+ document.removeEventListener('pointermove', onMove);
939
+ document.removeEventListener('pointerup', onUp);
940
+ document.removeEventListener('pointercancel', onUp);
941
+ }
917
942
  document.removeEventListener('keydown', onKey, true);
918
943
  document.removeEventListener('keyup', onKey, true);
919
944
  };
920
- }, [selectedStroke, store, screenToWorld, vp]);
945
+ }, [selectedStroke, store, screenToWorld, vp, pipeline]);
921
946
 
922
947
  // Only render when there's exactly one resizable stroke selected. Multi
923
948
  // resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
@@ -24,6 +24,8 @@ export interface AnnotationSelectionValue {
24
24
  selectedIds: string[];
25
25
  replace: (id: string | string[]) => void;
26
26
  add: (id: string | string[]) => void;
27
+ /** Drop ids from the selection (shift-click on a selected element). */
28
+ remove: (id: string | string[]) => void;
27
29
  toggle: (id: string) => void;
28
30
  clear: () => void;
29
31
  contains: (id: string) => boolean;
@@ -55,6 +57,11 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
55
57
  setSelectedIds((prev) => dedupe([...prev, ...incoming]));
56
58
  }, []);
57
59
 
60
+ const remove = useCallback((id: string | string[]) => {
61
+ const drop = new Set(Array.isArray(id) ? id : [id]);
62
+ setSelectedIds((prev) => prev.filter((x) => !drop.has(x)));
63
+ }, []);
64
+
58
65
  const toggle = useCallback((id: string) => {
59
66
  setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
60
67
  }, []);
@@ -76,8 +83,8 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
76
83
  const contains = useCallback((id: string) => containsRef.current.includes(id), []);
77
84
 
78
85
  const value = useMemo<AnnotationSelectionValue>(
79
- () => ({ selectedIds, replace, add, toggle, clear, contains }),
80
- [selectedIds, replace, add, toggle, clear, contains]
86
+ () => ({ selectedIds, replace, add, remove, toggle, clear, contains }),
87
+ [selectedIds, replace, add, remove, toggle, clear, contains]
81
88
  );
82
89
 
83
90
  return (
@@ -106,6 +106,12 @@ export interface CollabAwarenessState {
106
106
  * annotation-shaped is selected.
107
107
  */
108
108
  annotationSelection: string[];
109
+ /**
110
+ * DDR-242 AD5 / Task 22 — an annotation gesture in flight (drag, resize,
111
+ * draw), so peers see it move live. It never touches storage: the op
112
+ * commits once at gesture end, and this goes back to null.
113
+ */
114
+ annotationGesture?: AnnotationGesture | null;
109
115
  viewport: { x: number; y: number; zoom: number };
110
116
  /**
111
117
  * Soft editing-presence (Phase 30). Set while THIS peer (a human editing via
@@ -130,6 +136,20 @@ export interface CollabAwarenessState {
130
136
 
131
137
  export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
132
138
 
139
+ /** A live annotation gesture, in WORLD coordinates. */
140
+ export interface AnnotationGesture {
141
+ kind: 'move' | 'resize' | 'draw';
142
+ /** Elements the gesture moves / resizes (empty while drawing a new one). */
143
+ ids: string[];
144
+ /** move: the drag offset. */
145
+ dx?: number;
146
+ dy?: number;
147
+ /** resize: the box being dragged out; draw: a shape / sticky / section being drawn. */
148
+ box?: { x: number; y: number; w: number; h: number };
149
+ /** draw: pen ink so far, flat [x0,y0,x1,y1,…] (the latest part, capped). */
150
+ points?: number[];
151
+ }
152
+
133
153
  // ─────────────────────────────────────────────────────────────────────────────
134
154
  // Untrusted-input sanitization at the awareness trust boundary.
135
155
  //
@@ -214,6 +234,58 @@ function sanitizeCursor(raw: unknown): { x: number; y: number } | null {
214
234
  return isFiniteNum(c.x) && isFiniteNum(c.y) ? { x: c.x, y: c.y } : null;
215
235
  }
216
236
 
237
+ const GESTURE_KINDS = new Set(['move', 'resize', 'draw']);
238
+ const MAX_GESTURE_POINTS = 512; // numbers, i.e. 256 points
239
+ const GESTURE_COORD_LIMIT = 1e6;
240
+
241
+ function gestureNum(v: unknown): number | undefined {
242
+ if (typeof v !== 'number' || !Number.isFinite(v)) return undefined;
243
+ return Math.max(-GESTURE_COORD_LIMIT, Math.min(GESTURE_COORD_LIMIT, v));
244
+ }
245
+
246
+ /**
247
+ * Task 22 — a peer's gesture preview is untrusted like every other awareness
248
+ * field: kind allowlisted, ids through the same token + count gate as the
249
+ * selection, every number finite and clamped, pen points capped.
250
+ */
251
+ export function sanitizeAnnotationGesture(raw: unknown): AnnotationGesture | null {
252
+ if (!raw || typeof raw !== 'object') return null;
253
+ const g = raw as Record<string, unknown>;
254
+ if (typeof g.kind !== 'string' || !GESTURE_KINDS.has(g.kind)) return null;
255
+ const out: AnnotationGesture = {
256
+ kind: g.kind as AnnotationGesture['kind'],
257
+ ids: sanitizeAnnotationSelection(g.ids),
258
+ };
259
+ const dx = gestureNum(g.dx);
260
+ const dy = gestureNum(g.dy);
261
+ if (dx !== undefined && dy !== undefined) {
262
+ out.dx = dx;
263
+ out.dy = dy;
264
+ }
265
+ const b = g.box as Record<string, unknown> | undefined;
266
+ if (b && typeof b === 'object') {
267
+ const x = gestureNum(b.x);
268
+ const y = gestureNum(b.y);
269
+ const w = gestureNum(b.w);
270
+ const h = gestureNum(b.h);
271
+ if (x !== undefined && y !== undefined && w !== undefined && h !== undefined) {
272
+ out.box = { x, y, w: Math.abs(w), h: Math.abs(h) };
273
+ }
274
+ }
275
+ if (Array.isArray(g.points)) {
276
+ const pts: number[] = [];
277
+ const src = g.points.slice(-MAX_GESTURE_POINTS);
278
+ for (const v of src) {
279
+ const n = gestureNum(v);
280
+ if (n === undefined) break;
281
+ pts.push(n);
282
+ }
283
+ if (pts.length % 2 === 1) pts.pop();
284
+ if (pts.length) out.points = pts;
285
+ }
286
+ return out;
287
+ }
288
+
217
289
  function sanitizeViewport(raw: unknown): { x: number; y: number; zoom: number } {
218
290
  const fallback = { x: 0, y: 0, zoom: 1 };
219
291
  if (!raw || typeof raw !== 'object') return fallback;
@@ -284,6 +356,7 @@ export function sanitizeForeignState(clientID: number, state: unknown): ForeignA
284
356
  cursor: sanitizeCursor(s.cursor),
285
357
  selection: sanitizeSelection(s.selection),
286
358
  annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
359
+ annotationGesture: sanitizeAnnotationGesture(s.annotationGesture),
287
360
  viewport: sanitizeViewport(s.viewport),
288
361
  editing: sanitizeEditingState(s.editing),
289
362
  };
@@ -586,6 +659,7 @@ function createSession(slug: string): CollabSession {
586
659
  cursor: cur.cursor ?? null,
587
660
  selection: cur.selection ?? null,
588
661
  annotationSelection: cur.annotationSelection ?? [],
662
+ annotationGesture: null,
589
663
  viewport: cur.viewport ?? { x: 0, y: 0, zoom: 1 },
590
664
  editing: cur.editing ?? null,
591
665
  __connId: connId,
@@ -894,6 +968,8 @@ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Ele
894
968
  cursor: current.cursor ?? null,
895
969
  selection: current.selection ?? null,
896
970
  annotationSelection: current.annotationSelection ?? [],
971
+ // Kept across other fields' publishes (a cursor move mid-drag).
972
+ annotationGesture: current.annotationGesture ?? null,
897
973
  viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
898
974
  editing: current.editing ?? null,
899
975
  __connId: myConnId,
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "whiteboard-text-sections-live-previews",
6
+ "version": "1.5.0",
7
+ "date": "2026-09-30",
8
+ "kind": "improvement",
9
+ "title": "Smoother whiteboard editing, live together",
10
+ "summary": "Typing in stickies and shapes now wraps exactly as it will look, works with any input method (accents, Japanese), and is never lost — it reaches teammates within a second, and if someone deletes the note you are typing in, Enter puts it back with your text. Sections move, copy and delete together with everything in them, teammates see your drag or resize live before you let go, and undo only takes back your own changes.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "whiteboard-edits-sync-per-element",
15
+ "version": "1.5.0",
16
+ "date": "2026-09-30",
17
+ "kind": "improvement",
18
+ "title": "Whiteboard edits no longer overwrite each other",
19
+ "summary": "Each sticky, shape and arrow now saves on its own, so two people editing the same board keep both changes, and even text typed into the same sticky at once is merged. Existing boards convert automatically on start, and editing text is steadier: the cursor stays on a new line after Shift+Enter, double-clicking a word selects it, and Shift+click removes an item from a selection.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "file-tree-remembers-folders",
24
+ "version": "1.5.0",
25
+ "date": "2026-09-30",
26
+ "kind": "improvement",
27
+ "title": "The file tree remembers your folders",
28
+ "summary": "The Files panel now starts collapsed and keeps the folders you open, even after you switch panels, reload or restart the app. Opening a canvas unfolds the way to it.",
29
+ "surface": "design-ui"
30
+ },
4
31
  {
5
32
  "id": "comments-stay-where-you-put-them",
6
33
  "version": "1.4.6",
@@ -809,7 +809,11 @@ function collectAdoptManifest(cwd) {
809
809
  const files = [];
810
810
  try {
811
811
  for (const f of readdirSync(base)) {
812
- if (f.endsWith('.html') || f.endsWith('.annotations.svg')) {
812
+ if (
813
+ f.endsWith('.html') ||
814
+ f.endsWith('.annotations.json') ||
815
+ f.endsWith('.annotations.svg')
816
+ ) {
813
817
  try {
814
818
  files.push({ rel: `.design/${f}`, bytes: statSync(resolve(base, f)).size });
815
819
  } catch {
@@ -25,7 +25,7 @@ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
25
25
  // The canonical IGNORED set is the DDR-115 runtime-state taxonomy — kept in
26
26
  // lockstep with `apps/studio/git/service.ts` (isMaudeRuntimeState backstop)
27
27
  // and the repo's own `.gitignore`. VERSIONED content (canvases, `.meta.json`,
28
- // `*.annotations.svg`, `system/**`, `config.json`) is deliberately absent.
28
+ // `*.annotations.json` (v1 `.svg`), `system/**`, `config.json`) is deliberately absent.
29
29
  const lines = [
30
30
  BEGIN_MARKER,
31
31
  '# Maude design plugin runtime — gitignored even in linked mode (DDR-056/DDR-115).',
@@ -37,7 +37,8 @@
37
37
  * `gitignore-block.mjs` and the taxonomy in `apps/studio/git/service.ts`.
38
38
  */
39
39
  const VERSIONED_PATTERNS = [
40
- { pattern: '*.annotations.svg', what: 'canvas draw layers (annotations)' },
40
+ { pattern: '*.annotations.json', what: 'canvas draw layers (annotations)' },
41
+ { pattern: '*.annotations.svg', what: 'canvas draw layers (annotations, v1 form)' },
41
42
  { pattern: '*.meta.json', what: 'canvas sidecars (layout, artboards, tags)' },
42
43
  { pattern: '*.tsx', what: 'canvas bodies' },
43
44
  { pattern: 'config.json', what: 'the project design config' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "1.4.6",
3
+ "version": "1.5.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -58,13 +58,13 @@
58
58
  "test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@1agh/maude-darwin-arm64": "1.4.6",
62
- "@1agh/maude-darwin-x64": "1.4.6",
63
- "@1agh/maude-linux-arm64": "1.4.6",
64
- "@1agh/maude-linux-arm64-musl": "1.4.6",
65
- "@1agh/maude-linux-x64": "1.4.6",
66
- "@1agh/maude-linux-x64-musl": "1.4.6",
67
- "@1agh/maude-win32-x64": "1.4.6"
61
+ "@1agh/maude-darwin-arm64": "1.5.1",
62
+ "@1agh/maude-darwin-x64": "1.5.1",
63
+ "@1agh/maude-linux-arm64": "1.5.1",
64
+ "@1agh/maude-linux-arm64-musl": "1.5.1",
65
+ "@1agh/maude-linux-x64": "1.5.1",
66
+ "@1agh/maude-linux-x64-musl": "1.5.1",
67
+ "@1agh/maude-win32-x64": "1.5.1"
68
68
  },
69
69
  "files": [
70
70
  "cli",
@@ -131,6 +131,7 @@
131
131
  "@decimalturn/toml-patch": "3.0.5",
132
132
  "ajv": "^8.20.0",
133
133
  "ajv-formats": "^3.0.1",
134
+ "diff": "8.0.4",
134
135
  "happy-dom": "20.10.6",
135
136
  "svgo": "^4.0.1",
136
137
  "yaml": "2.9.0"
@@ -13,7 +13,7 @@
13
13
  * a VERBATIM brief and inserts the matching generated artboards right here, below
14
14
  * this frame — the board becomes a living brief you sketch and Claude fills in.
15
15
  *
16
- * The annotation layer lives in a sibling `<slug>.annotations.svg` (NEVER in this
16
+ * The annotation layer lives in a sibling `<slug>.annotations.json` (NEVER in this
17
17
  * file), so your notes stay floating on top across every regeneration. No design
18
18
  * system tokens or classes are wired in — the board is a neutral surface; the
19
19
  * generated artboards bring the DS with them on ingest.
@@ -1,36 +0,0 @@
1
- // Which SVG an annotation edit names as its base (DDR-241 accepted revisions).
2
- //
3
- // The project merges an annotation proposal element by element against the
4
- // text it was derived from. The canvas used to send the CANONICAL
5
- // re-serialization of the strokes it held — fine while the stored text is
6
- // canonical too, and silently fatal when it is not: an element written by an
7
- // older serializer, a hand edit or any other writer then looks changed on BOTH
8
- // sides, the merge refuses it as a conflict, and the person's edit is reverted
9
- // without a word (plan T31, L09 arrowheads on a non-canonical arrow).
10
- //
11
- // An edit made on exactly the state this canvas last RECEIVED names the text
12
- // it received — the project then sees "nothing changed underneath", accepts
13
- // the edit whole, and the stored text becomes canonical as a side effect. Any
14
- // other state (edits stacked before the echo returns) is this canvas's own
15
- // canonical output, which is what the project holds after accepting it.
16
-
17
- import { type Stroke, strokesToSvg } from './annotations-model.ts';
18
-
19
- export interface ReceivedAnnotations {
20
- /** The SVG text exactly as the project delivered it. */
21
- raw: string;
22
- /** The same state as this client serializes it. */
23
- canon: string;
24
- }
25
-
26
- export function receivedAnnotations(raw: string, strokes: readonly Stroke[]): ReceivedAnnotations {
27
- return { raw, canon: strokesToSvg(strokes) };
28
- }
29
-
30
- export function annotationEditBase(
31
- before: readonly Stroke[],
32
- received: ReceivedAnnotations | null
33
- ): string {
34
- const canon = strokesToSvg(before);
35
- return received && received.canon === canon ? received.raw : canon;
36
- }