@1agh/maude 1.2.0 → 1.3.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 (83) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +689 -36
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-edit.ts +391 -7
  10. package/apps/studio/canvas-lib.tsx +82 -18
  11. package/apps/studio/canvas-notice-message.ts +16 -0
  12. package/apps/studio/canvas-notifications.tsx +19 -0
  13. package/apps/studio/canvas-shell.tsx +57 -6
  14. package/apps/studio/client/app.jsx +686 -241
  15. package/apps/studio/client/apply-edit-request.ts +55 -0
  16. package/apps/studio/client/export-center.jsx +111 -61
  17. package/apps/studio/client/github.js +38 -1
  18. package/apps/studio/client/index-loader.ts +76 -0
  19. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  20. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  21. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  22. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  23. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  24. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  25. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  26. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  27. package/apps/studio/client/photo-knobs.jsx +3 -0
  28. package/apps/studio/client/share-dialog.jsx +91 -0
  29. package/apps/studio/client/share-link.js +94 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +35 -18
  31. package/apps/studio/client/styles/4-components.css +18 -1
  32. package/apps/studio/client/tree-row-menu.jsx +48 -3
  33. package/apps/studio/client/whats-new.jsx +26 -32
  34. package/apps/studio/cloud/endpoints.ts +132 -0
  35. package/apps/studio/collab/index.ts +4 -0
  36. package/apps/studio/collab/persistence.ts +5 -1
  37. package/apps/studio/collab/registry.ts +9 -3
  38. package/apps/studio/collab/room.ts +13 -2
  39. package/apps/studio/context.ts +59 -0
  40. package/apps/studio/dist/client.bundle.js +1586 -1554
  41. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  42. package/apps/studio/dist/runtime/sonner.js +1 -0
  43. package/apps/studio/dist/styles.css +1 -1
  44. package/apps/studio/http.ts +289 -18
  45. package/apps/studio/inspect.ts +19 -0
  46. package/apps/studio/managed-projects.ts +150 -0
  47. package/apps/studio/notifications.tsx +292 -0
  48. package/apps/studio/runtime-bundle.ts +2 -0
  49. package/apps/studio/server.ts +59 -3
  50. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  51. package/apps/studio/sync/accepted-link.ts +320 -0
  52. package/apps/studio/sync/action-stage.ts +343 -0
  53. package/apps/studio/sync/agent.ts +12 -60
  54. package/apps/studio/sync/cell-file-events.ts +3 -0
  55. package/apps/studio/sync/codec.ts +73 -6
  56. package/apps/studio/sync/ctl-provider.ts +15 -2
  57. package/apps/studio/sync/document-discovery.ts +81 -0
  58. package/apps/studio/sync/file-membership.ts +10 -2
  59. package/apps/studio/sync/file-plane.ts +309 -26
  60. package/apps/studio/sync/index.ts +1078 -19
  61. package/apps/studio/sync/migrate-seed.ts +110 -19
  62. package/apps/studio/sync/poke.ts +4 -2
  63. package/apps/studio/sync/presentation.ts +285 -2
  64. package/apps/studio/sync/projection.ts +820 -23
  65. package/apps/studio/sync/repeated-module.ts +73 -0
  66. package/apps/studio/sync/revision-barrier.ts +129 -0
  67. package/apps/studio/sync/seed-repair.ts +46 -0
  68. package/apps/studio/sync/source-merge.ts +100 -0
  69. package/apps/studio/sync/source-ops.ts +289 -0
  70. package/apps/studio/sync/source-recovery.ts +70 -0
  71. package/apps/studio/sync/source-validation.ts +56 -0
  72. package/apps/studio/sync/status.ts +79 -1
  73. package/apps/studio/sync/transaction-client.ts +460 -0
  74. package/apps/studio/sync/writer-registry.ts +236 -0
  75. package/apps/studio/text-caret.ts +35 -0
  76. package/apps/studio/undo-hud.tsx +9 -87
  77. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  78. package/apps/studio/whats-new.json +101 -0
  79. package/cli/lib/harness/codex-runtime.mjs +4 -1
  80. package/package.json +9 -8
  81. package/plugins/design/dependencies.json +3 -3
  82. package/plugins/design/templates/_shell.html +47 -0
  83. package/plugins/flow/dependencies.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Maude
2
2
 
3
- A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
3
+ A personal marketplace of Claude Code and Codex plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
4
4
 
5
5
  <!-- Demo video lives at https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4 once uploaded.
6
6
  Until then the inline tag below stays a soft 404; landing page at https://maude.sh autoplays the same file. -->
@@ -18,6 +18,8 @@ Plus the **`maude`** CLI — `maude init` scaffolds a fresh `.ai/` workspace fro
18
18
 
19
19
  ## Quick start
20
20
 
21
+ **Codex CLI:** use the [native plugin installation guide](docs/native-plugins.md). It uses the same workflow procedures; Studio ACP support is a separate follow-up.
22
+
21
23
  > **Using Maude Desktop?** With only **Claude Code installed**, the desktop app's built-in chat panel auto-loads the `design` + `flow` plugins for its session — `/design:*` and `/flow:*` just work, nothing to install (power users who already installed the plugins see a no-op, no double-load). The steps below are the **manual / power-user path** — for the `maude` CLI, the web `maude design serve` flow, or driving `/design:*` from your own terminal Claude Code. Full detail: [maude.sh/desktop](https://maude.sh/desktop).
22
24
 
23
25
  ### 1. Add the marketplace inside Claude Code
@@ -101,14 +101,28 @@ const BEAT_MS = 4000;
101
101
 
102
102
  interface AgentActivityTracker {
103
103
  onUpdate(update: SessionUpdate): void;
104
- /** Turn finished (normal, cancel, error) or socket closed — clear banners. */
105
- endTurn(): void;
104
+ /**
105
+ * Turn finished (normal, cancel, error) or socket closed — clear banners.
106
+ * T16: `done` publishes what the turn wrote as ONE project action; anything
107
+ * else (cancel, error, token limit, reap) keeps it unpublished for the
108
+ * person's decision. Default `failed` — only a clean end publishes.
109
+ */
110
+ endTurn(outcome?: 'done' | 'failed'): void;
111
+ /** The prompt this turn answers — the action's history label. */
112
+ beginTurn?(prompt: string): void;
106
113
  }
107
114
 
108
115
  /** Exported for tests (acp-ai-activity.test.ts); not part of the Acp surface. */
109
- export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentActivityTracker {
116
+ export function createAgentActivityTracker(
117
+ ctx: Context,
118
+ ai: AiActivity,
119
+ chatId = 'default'
120
+ ): AgentActivityTracker {
110
121
  const kindByToolCall = new Map<string, string>(); // toolCallId → kind
111
122
  const lastBeat = new Map<string, number>(); // ai-activity key → last beat ms
123
+ const actionKey = `acp:${chatId}`;
124
+ let turnLabel = 'Claude edit';
125
+ let actionOpen = false;
112
126
 
113
127
  function keyFor(p: unknown): string | null {
114
128
  if (typeof p !== 'string' || !p) return null;
@@ -145,6 +159,11 @@ export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentA
145
159
  for (const p of candidates) {
146
160
  const key = keyFor(p);
147
161
  if (!key) continue;
162
+ if (!actionOpen) {
163
+ // T16 — the turn's first canvas edit opens its project action.
164
+ actionOpen = true;
165
+ ctx.syncControl?.current?.()?.beginAiAction?.(actionKey, turnLabel);
166
+ }
148
167
  const last = lastBeat.get(key);
149
168
  if (last == null) {
150
169
  ai.start(key, AGENT_AUTHOR);
@@ -156,13 +175,25 @@ export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentA
156
175
  }
157
176
  }
158
177
 
159
- function endTurn(): void {
178
+ function endTurn(outcome: 'done' | 'failed' = 'failed'): void {
160
179
  for (const key of lastBeat.keys()) ai.end(key);
161
180
  lastBeat.clear();
162
181
  kindByToolCall.clear();
182
+ if (actionOpen) {
183
+ actionOpen = false;
184
+ void ctx.syncControl
185
+ ?.current?.()
186
+ ?.endAiAction?.(actionKey, outcome)
187
+ ?.catch(() => {});
188
+ }
189
+ }
190
+
191
+ function beginTurn(prompt: string): void {
192
+ const words = prompt.replace(/\s+/g, ' ').trim().slice(0, 80);
193
+ turnLabel = words ? `Claude: ${words}` : 'Claude edit';
163
194
  }
164
195
 
165
- return { onUpdate, endTurn };
196
+ return { onUpdate, endTurn, beginTurn };
166
197
  }
167
198
 
168
199
  // ── Detached bridge lifetime (feature-acp-write-path-scope Addendum, Task 8) ──
@@ -328,7 +359,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
328
359
  return null;
329
360
  }
330
361
  }
331
- const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity) : null;
362
+ const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity, chatId) : null;
332
363
  // Declared before the bridge so the callbacks below can close over it —
333
364
  // they fan out to `entry.sinks`, which changes as sockets come and go,
334
365
  // rather than capturing one socket the way the per-socket design did.
@@ -548,9 +579,14 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
548
579
  // first await, so a socket that closes during the spawn still counts as a
549
580
  // live turn rather than an abandoned bridge.
550
581
  entry.turnActive = true;
582
+ entry.tracker?.beginTurn?.(text);
583
+ let turnOutcome: 'done' | 'failed' = 'failed';
551
584
  try {
552
585
  await bridge.ensureStarted();
553
586
  const { stopReason } = await bridge.prompt(text, sanitizeChatId(chatId));
587
+ // Only a turn that ended on its own publishes; a cancel, a refusal or a
588
+ // token limit leaves its edits for the person (T16).
589
+ if (stopReason === 'end_turn') turnOutcome = 'done';
554
590
  broadcast(entry, { t: 'connected', sessionId: bridge.sessionId });
555
591
  broadcast(entry, { t: 'turn-end', stopReason });
556
592
  } catch (err) {
@@ -564,7 +600,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
564
600
  // RC5 — the turn is over (success, error, or cancel-induced stop): clear
565
601
  // every "Claude is editing …" banner this turn raised. The 30 s heartbeat
566
602
  // grace still covers a crashed dev-server round-trip.
567
- entry.tracker?.endTurn();
603
+ entry.tracker?.endTurn(turnOutcome);
568
604
  // The turn was the only reason a detached bridge was being kept alive; now
569
605
  // that it's done, let the ordinary TTL run out rather than holding the
570
606
  // subprocess indefinitely for a client that never came back.
@@ -1,3 +1,4 @@
1
+ import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
1
2
  /**
2
3
  * @file annotations-layer.tsx — FigJam-style annotation overlay
3
4
  * @scope apps/studio/annotations-layer.tsx
@@ -76,6 +77,7 @@ import {
76
77
  type EllipseStroke,
77
78
  FILL_PALETTE,
78
79
  fmtEqual,
80
+ grownStickyBox,
79
81
  HALO_PAD_PX,
80
82
  HIGHLIGHTER_PALETTE,
81
83
  HIGHLIGHTER_WIDTHS,
@@ -169,7 +171,7 @@ import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-comm
169
171
  import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
170
172
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
171
173
  import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
172
- import { mountCaret, placeCaretAt } from './text-caret.ts';
174
+ import { collapseEntrySelectAll, mountCaret, placeCaretAt } from './text-caret.ts';
173
175
  import {
174
176
  AnnotationResizeOverlay,
175
177
  bboxResize,
@@ -414,7 +416,7 @@ function resolveAssetHref(href: string): string {
414
416
  * recognize — keeping the `<image>` element, dropping only the attribute —
415
417
  * so the server's STORED + broadcast SVG silently diverges from whatever the
416
418
  * client just sent. That divergence defeats the collab-echo self-suppression
417
- * guard (the `recentSelfSvgsRef` history, near `putStrokes` below): the
419
+ * guard (the `annotationEchoRef` operation history, near `putStrokes` below): the
418
420
  * echo's content no longer matches anything we recorded as "already
419
421
  * applied", so a real `setStrokesState` fires from the (href-stripped)
420
422
  * server copy — which can wipe out a SIBLING stroke's still-in-flight
@@ -467,6 +469,17 @@ export function reconcileCommit(
467
469
  * ones that are still purely local, not-yet-synced optimistic previews (an
468
470
  * ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
469
471
  * never have known about in the first place.
472
+ *
473
+ * issue-106 deliberately does NOT carve out the stroke a local editor has open.
474
+ * That was tried: hold back its `text` so a peer's commit could not overwrite
475
+ * the editor. But this function's output IS the persisted store — it is
476
+ * serialized to `*.annotations.svg` and re-broadcast on the next commit of ANY
477
+ * stroke — so holding a field back doesn't shield a view, it silently reverts
478
+ * the peer's write and republishes the stale text under the local user's name,
479
+ * for as long as the editor stays open. The editor never needed it: the clobber
480
+ * was React rewriting an UNCONTROLLED contentEditable, which `useSessionValue`
481
+ * fixes at the DOM, and the commit reads `innerText` off the node rather than
482
+ * off the stroke. Last-write-wins stays the model here.
470
483
  */
471
484
  export function reconcileForeignEcho(
472
485
  prev: readonly Stroke[],
@@ -1101,7 +1114,7 @@ export function AnnotationsLayer() {
1101
1114
  // the resurrection to every collab peer). Tracked explicitly here instead
1102
1115
  // of inferred: `deleteStrokes` records every id it removes; the swap
1103
1116
  // checks (and consumes) this before assuming absence means lag. Bounded
1104
- // like `recentSelfSvgsRef` below — only ever holds ids a delete has
1117
+ // like the annotation echo history below — only ever holds ids a delete has
1105
1118
  // touched, which is small in practice, but capped for safety.
1106
1119
  const DELETED_STROKE_IDS_CAP = 128;
1107
1120
  const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
@@ -1140,29 +1153,10 @@ export function AnnotationsLayer() {
1140
1153
  if (!ghostCapable || !visible) setGhost(null);
1141
1154
  }, [ghostCapable, visible]);
1142
1155
 
1143
- // Load existing annotations on mount.
1144
- // Self-echo suppression (Phase 8 Task 5, hardened — feature-bulk-media-
1145
- // insert follow-up). A single "last applied" string only catches the MOST
1146
- // RECENT self-write: when the chain fires several rapid commits (a batch
1147
- // drop), the server's broadcast of an EARLIER commit can arrive after a
1148
- // LATER local commit has already moved "last applied" on — so the earlier
1149
- // echo no longer matches, gets misread as a foreign change, and rolls
1150
- // local state BACK to that stale snapshot. This is the confirmed cause of
1151
- // images silently vanishing after a multi-file drop (live-tested: 12
1152
- // mismatched/misapplied echoes correlated exactly with lost strokes across
1153
- // a 30-batch stress run). Track a bounded HISTORY of our own recent writes
1154
- // instead of just the latest one, so an out-of-order echo of any recent
1155
- // self-write is still recognized and suppressed.
1156
- const RECENT_SELF_SVG_CAP = 64;
1157
- const recentSelfSvgsRef = useRef<Set<string>>(new Set());
1158
- const rememberSelfSvg = useCallback((svg: string) => {
1159
- const set = recentSelfSvgsRef.current;
1160
- set.add(svg);
1161
- if (set.size > RECENT_SELF_SVG_CAP) {
1162
- const oldest = set.values().next().value;
1163
- if (oldest !== undefined) set.delete(oldest);
1164
- }
1165
- }, []);
1156
+ // Match specific authored operations, including delayed earlier PUTs. A
1157
+ // peer undo/delete can legitimately return to any previously rendered SVG.
1158
+ const annotationEchoRef = useRef(createAnnotationEchoGuard());
1159
+ const annotationsChangedRef = useRef(false);
1166
1160
  useEffect(() => {
1167
1161
  const file = deriveFile();
1168
1162
  fileRef.current = file;
@@ -1173,11 +1167,10 @@ export function AnnotationsLayer() {
1173
1167
  })
1174
1168
  .then((r) => (r.ok ? r.text() : ''))
1175
1169
  .then((text) => {
1176
- if (cancelled) return;
1170
+ if (cancelled || annotationsChangedRef.current) return;
1177
1171
  const loaded = svgToStrokes(text);
1178
1172
  if (loaded.length) {
1179
1173
  setStrokesState(loaded);
1180
- rememberSelfSvg(text);
1181
1174
  }
1182
1175
  })
1183
1176
  .catch(() => {
@@ -1186,37 +1179,18 @@ export function AnnotationsLayer() {
1186
1179
  return () => {
1187
1180
  cancelled = true;
1188
1181
  };
1189
- }, [rememberSelfSvg]);
1190
-
1191
- // Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
1192
- // other tabs. Bail when the incoming SVG STRING matches any RECENT
1193
- // self-write (covers the local echo round-trip, including an
1194
- // out-of-order one, without missing real foreign changes). The prior
1195
- // length+first/last-id check was wrong: a resize / move keeps the same id
1196
- // list, so all three predicates matched even though geometry changed —
1197
- // foreign edits silently disappeared.
1182
+ }, []);
1183
+
1198
1184
  const collab = useCollab();
1199
1185
  useEffect(() => {
1200
1186
  if (!collab) return;
1201
- const map = collab.doc.getMap<string>('annotations');
1202
- const apply = () => {
1203
- const svg = map.get('svg');
1204
- if (typeof svg !== 'string' || !svg) return;
1205
- if (recentSelfSvgsRef.current.has(svg)) return;
1206
- rememberSelfSvg(svg);
1187
+ return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
1188
+ annotationsChangedRef.current = true;
1189
+ if (annotationEchoRef.current.isOwn(svg, writeId)) return;
1207
1190
  const incoming = svgToStrokes(svg);
1208
1191
  setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
1209
- };
1210
- apply();
1211
- map.observe(apply);
1212
- return () => {
1213
- try {
1214
- map.unobserve(apply);
1215
- } catch {
1216
- /* doc destroyed before unmount */
1217
- }
1218
- };
1219
- }, [collab, rememberSelfSvg]);
1192
+ });
1193
+ }, [collab]);
1220
1194
 
1221
1195
  const undoStack = useUndoStackOptional();
1222
1196
  const undoSinks = useUndoSinks();
@@ -1252,50 +1226,55 @@ export function AnnotationsLayer() {
1252
1226
  * we push a command, so the server only sees one PUT per edit instead
1253
1227
  * of two-step racing.
1254
1228
  */
1255
- const putStrokes = useCallback(
1256
- (next: readonly Stroke[], before: readonly Stroke[]) => {
1257
- // See reconcileCommit — a direct setStrokesState(next) here can
1258
- // clobber a sibling file's concurrent optimistic insert; folding
1259
- // blindly against `prev` (no baseline) can just as easily revert this
1260
- // very mutation's own delete. `before` (this command's own baseline)
1261
- // disambiguates the two.
1262
- setStrokesState((prev) => reconcileCommit(prev, before, next));
1263
- const file = fileRef.current;
1264
- if (!file) return Promise.resolve();
1265
- const persistable = next.some(isEphemeralHref)
1266
- ? next.filter((s) => !isEphemeralHref(s))
1267
- : next;
1268
- const svg = strokesToSvg(persistable);
1269
- // Phase 8 Task 5 — record the SVG we just authored locally so the
1270
- // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
1271
- // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1272
- rememberSelfSvg(svg);
1273
- const dispatch = () =>
1274
- fetch('/_api/annotations', {
1275
- method: 'PUT',
1276
- headers: { 'Content-Type': 'application/json' },
1277
- body: JSON.stringify({ file, svg }),
1229
+ const putStrokes = useCallback((next: readonly Stroke[], before: readonly Stroke[]) => {
1230
+ // See reconcileCommit — a direct setStrokesState(next) here can
1231
+ // clobber a sibling file's concurrent optimistic insert; folding
1232
+ // blindly against `prev` (no baseline) can just as easily revert this
1233
+ // very mutation's own delete. `before` (this command's own baseline)
1234
+ // disambiguates the two.
1235
+ annotationsChangedRef.current = true;
1236
+ setStrokesState((prev) => reconcileCommit(prev, before, next));
1237
+ const file = fileRef.current;
1238
+ if (!file) return Promise.resolve();
1239
+ const persistable = next.some(isEphemeralHref) ? next.filter((s) => !isEphemeralHref(s)) : next;
1240
+ const svg = strokesToSvg(persistable);
1241
+ // What this edit was derived from — the project merges a peer's
1242
+ // concurrent strokes from it instead of replacing them (DDR-241).
1243
+ const persistableBefore = before.some(isEphemeralHref)
1244
+ ? before.filter((s) => !isEphemeralHref(s))
1245
+ : before;
1246
+ const base = strokesToSvg(persistableBefore);
1247
+ // Phase 8 Task 5 — record the SVG we just authored locally so the
1248
+ // server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
1249
+ // Y.Map.observe) doesn't trigger a redundant setStrokesState.
1250
+ const writeId = crypto.randomUUID();
1251
+ annotationEchoRef.current.remember(writeId, svg);
1252
+ const dispatch = () =>
1253
+ fetch('/_api/annotations', {
1254
+ method: 'PUT',
1255
+ headers: { 'Content-Type': 'application/json' },
1256
+ body: JSON.stringify({ file, svg, writeId, base }),
1257
+ })
1258
+ .then((r) => {
1259
+ // A refused save (403 read-only, 405 at a proxy door) previously
1260
+ // dissolved here without a trace — the user kept drawing on state
1261
+ // that never reached disk, a peer, or a reload (the cloud
1262
+ // canvas-writes RCA). Optimistic local state is still the right
1263
+ // UX; a persistence failure being INVISIBLE is not.
1264
+ if (!r.ok) {
1265
+ annotationEchoRef.current.forget(writeId);
1266
+ console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
1267
+ }
1268
+ return undefined;
1278
1269
  })
1279
- .then((r) => {
1280
- // A refused save (403 read-only, 405 at a proxy door) previously
1281
- // dissolved here without a trace — the user kept drawing on state
1282
- // that never reached disk, a peer, or a reload (the cloud
1283
- // canvas-writes RCA). Optimistic local state is still the right
1284
- // UX; a persistence failure being INVISIBLE is not.
1285
- if (!r.ok) {
1286
- console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
1287
- }
1288
- return undefined;
1289
- })
1290
- .catch(() => {
1291
- /* swallow — user sees uncommitted state until the next stroke */
1292
- });
1293
- const chained = putChainRef.current.then(dispatch, dispatch);
1294
- putChainRef.current = chained;
1295
- return chained;
1296
- },
1297
- [rememberSelfSvg]
1298
- );
1270
+ .catch(() => {
1271
+ annotationEchoRef.current.forget(writeId);
1272
+ /* Pending persistence UX is handled by the project outbox work. */
1273
+ });
1274
+ const chained = putChainRef.current.then(dispatch, dispatch);
1275
+ putChainRef.current = chained;
1276
+ return chained;
1277
+ }, []);
1299
1278
 
1300
1279
  // Register the strokes put sink with the undo provider so the rebuilt
1301
1280
  // AnnotationStrokesCommand (after a canvas switch + return) routes through
@@ -1745,14 +1724,14 @@ export function AnnotationsLayer() {
1745
1724
  // chain-driven setStrokesState queued in the same batch.
1746
1725
  setStrokesState((prev) => prev.filter((s) => s.id !== id));
1747
1726
  deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
1748
- showCanvasToast(`Image upload failed — ${res.error}`);
1727
+ showCanvasToast(`Image upload failed — ${res.error}`, 'error');
1749
1728
  }
1750
1729
  URL.revokeObjectURL(blobUrl);
1751
1730
  });
1752
1731
  };
1753
1732
  probe.onerror = () => {
1754
1733
  URL.revokeObjectURL(blobUrl);
1755
- showCanvasToast('Could not read that image file');
1734
+ showCanvasToast('Could not read that image file', 'error');
1756
1735
  };
1757
1736
  probe.src = blobUrl;
1758
1737
  },
@@ -1797,7 +1776,7 @@ export function AnnotationsLayer() {
1797
1776
  const h = mediaKind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
1798
1777
  void uploadAsset(file).then((res) => {
1799
1778
  if (!('path' in res)) {
1800
- showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
1779
+ showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`, 'error');
1801
1780
  return;
1802
1781
  }
1803
1782
  const id = rid();
@@ -2884,13 +2863,24 @@ export function AnnotationsLayer() {
2884
2863
  // Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
2885
2864
  // no trim) and the card persists even when blank, so this only updates text.
2886
2865
  const commitStickyText = useCallback(
2887
- (id: string, text: string, fmt?: EditorFmt) => {
2866
+ (id: string, text: string, fmt?: EditorFmt, measuredH?: number) => {
2888
2867
  const prev = strokesRef.current;
2889
2868
  const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
2890
2869
  | StickyStroke
2891
2870
  | undefined;
2892
- if (!existing || (existing.text === text && fmtEqual(existing, fmt))) return;
2893
- const next = prev.map((s) => (s.id === id ? { ...existing, text, ...normFmt(fmt) } : s));
2871
+ if (!existing) return;
2872
+ // issue-106 — grow the card to whatever the editor actually rendered, so
2873
+ // the lines the user just typed survive the return to the clipped read
2874
+ // view. Null when the text already fits (the common case).
2875
+ // The growth rides along with a real edit — it is NOT itself a reason to
2876
+ // write. Otherwise opening a peer's already-overflowing note and clicking
2877
+ // away, having typed nothing, would produce an undo entry, a
2878
+ // full-document PUT and a sync broadcast.
2879
+ if (existing.text === text && fmtEqual(existing, fmt)) return;
2880
+ const grown = measuredH == null ? null : grownStickyBox(existing, measuredH);
2881
+ const next = prev.map((s) =>
2882
+ s.id === id ? { ...existing, text, ...normFmt(fmt), ...(grown ?? {}) } : s
2883
+ );
2894
2884
  commitStrokes(prev, next, 'edit sticky');
2895
2885
  },
2896
2886
  [commitStrokes]
@@ -2970,14 +2960,14 @@ export function AnnotationsLayer() {
2970
2960
  editingTargetRef.current = editingTarget;
2971
2961
 
2972
2962
  const commitEditing = useCallback(
2973
- (text: string, fmt?: EditorFmt) => {
2963
+ (text: string, fmt?: EditorFmt, measuredH?: number) => {
2974
2964
  const target = editingTargetRef.current;
2975
2965
  setEditingId(null);
2976
2966
  setPendingText(null);
2977
2967
  setEditCaretPoint(null);
2978
2968
  if (!target) return;
2979
2969
  if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
2980
- else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
2970
+ else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt, measuredH);
2981
2971
  else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
2982
2972
  else if (target.kind === 'section') {
2983
2973
  const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
@@ -3902,7 +3892,7 @@ function AnnotationsSvg({
3902
3892
  editCaretPoint: { x: number; y: number } | null;
3903
3893
  /** Live default ink (theme-aware) for a not-yet-born pending text caret. */
3904
3894
  inkColor: string;
3905
- onCommitEdit: (text: string, fmt?: EditorFmt) => void;
3895
+ onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
3906
3896
  onCancelEdit: () => void;
3907
3897
  }) {
3908
3898
  const [, force] = useState({});
@@ -4118,7 +4108,7 @@ function AnnotEditors({
4118
4108
  anchoredExisting: TextStroke | undefined;
4119
4109
  caretPoint: { x: number; y: number } | null;
4120
4110
  inkColor: string;
4121
- onCommitEdit: (text: string, fmt?: EditorFmt) => void;
4111
+ onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
4122
4112
  onCancelEdit: () => void;
4123
4113
  }) {
4124
4114
  const target = worldRef?.current ?? null;
@@ -4346,6 +4336,33 @@ function useEditorCaret(
4346
4336
  return { onPointerDown, onPointerUp };
4347
4337
  }
4348
4338
 
4339
+ /**
4340
+ * issue-106 — the body an annotation editor hands React as its contentEditable
4341
+ * children, snapshotted for the life of ONE edit session.
4342
+ *
4343
+ * These editors are uncontrolled: the user types straight into the DOM. If the
4344
+ * rendered children value changes while the session is open, React writes the
4345
+ * new string into the live node and everything typed since — line breaks
4346
+ * included — is gone. That is exactly what a peer's commit arriving over
4347
+ * shared-doc sync (DDR-064) used to do, and what the hazard note above
4348
+ * `useEditorFormat` warns about for local mutations. Snapshotting per target id
4349
+ * makes the children a constant for the session, so no store update can reach
4350
+ * the DOM; the commit path reads `innerText` from the node itself, which is the
4351
+ * user's real content either way. Re-keys when the editor is reused for a
4352
+ * different stroke.
4353
+ *
4354
+ * `listType` is snapshotted through the same hook: the body is prefixed with
4355
+ * list markers at OPEN time and stripped again at commit, so if a peer flipped
4356
+ * the stroke's list style mid-session the strip would run a different rule than
4357
+ * the prefix did — turning a bullet into a literal "• " in the stored text, or
4358
+ * eating a leading "1. " the user actually typed.
4359
+ */
4360
+ function useSessionValue<T>(id: string, compute: () => T): T {
4361
+ const ref = useRef<{ id: string; value: T } | null>(null);
4362
+ if (ref.current === null || ref.current.id !== id) ref.current = { id, value: compute() };
4363
+ return ref.current.value;
4364
+ }
4365
+
4349
4366
  function TextEditor({
4350
4367
  anchorId,
4351
4368
  host,
@@ -4364,9 +4381,10 @@ function TextEditor({
4364
4381
  const ref = useRef<HTMLDivElement | null>(null);
4365
4382
  // Show list markers WHILE editing so the read↔edit swap doesn't flicker
4366
4383
  // (item 4c) — stripped back to raw text on commit.
4367
- const initial = listPrefixedBody(existing?.text ?? '', existing?.listType);
4368
- const initialRef = useRef(initial);
4369
- initialRef.current = initial;
4384
+ const sessionListType = useSessionValue(anchorId, () => existing?.listType);
4385
+ const initial = useSessionValue(anchorId, () =>
4386
+ listPrefixedBody(existing?.text ?? '', sessionListType)
4387
+ );
4370
4388
  // Cmd/Ctrl+B/I/U formatting while editing (item 4d).
4371
4389
  const {
4372
4390
  fmtRef,
@@ -4385,8 +4403,8 @@ function TextEditor({
4385
4403
  // effect below can depend on it directly instead of its own copy of
4386
4404
  // existing?.listType (lint/correctness/useExhaustiveDependencies).
4387
4405
  const toCommittedText = useCallback(
4388
- (raw: string) => stripEditorMarkers(stripJumpSentinel(raw), existing?.listType),
4389
- [existing?.listType]
4406
+ (raw: string) => stripEditorMarkers(stripJumpSentinel(raw), sessionListType),
4407
+ [sessionListType]
4390
4408
  );
4391
4409
 
4392
4410
  // Caret-at-click on entry + custom blinking caret + click re-placement
@@ -4471,6 +4489,14 @@ function TextEditor({
4471
4489
  onCancel();
4472
4490
  return;
4473
4491
  }
4492
+ // issue-106 — Shift+Enter ADDS a line. On keyboard entry the whole
4493
+ // body is still select-all'd (the retype convention), so letting the
4494
+ // break replace the selection wiped the text. Collapse that entry
4495
+ // selection to its end first; a user's own partial selection is left
4496
+ // alone. See collapseEntrySelectAll.
4497
+ if (e.key === 'Enter' && e.shiftKey && ref.current) {
4498
+ collapseEntrySelectAll(ref.current, window);
4499
+ }
4474
4500
  // Unified across every text surface: plain Enter commits,
4475
4501
  // Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
4476
4502
  // +Enter also commits AND chains a connected sibling (quick-create).
@@ -4504,7 +4530,7 @@ function StickyEditor({
4504
4530
  }: {
4505
4531
  sticky: StickyStroke;
4506
4532
  caretPoint?: { x: number; y: number } | null;
4507
- onCommit: (text: string, fmt?: EditorFmt) => void;
4533
+ onCommit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
4508
4534
  onCancel: () => void;
4509
4535
  }) {
4510
4536
  // A flex-centered contentEditable (NOT a textarea) so the edit view matches
@@ -4513,6 +4539,8 @@ function StickyEditor({
4513
4539
  // Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
4514
4540
  const ref = useRef<HTMLDivElement | null>(null);
4515
4541
  const doneRef = useRef(false);
4542
+ const sessionListType = useSessionValue(sticky.id, () => sticky.listType);
4543
+ const sessionBody = useSessionValue(sticky.id, () => stickyBodyText(sticky));
4516
4544
  const {
4517
4545
  fmtRef,
4518
4546
  style: fmtStyle,
@@ -4528,7 +4556,16 @@ function StickyEditor({
4528
4556
  const commit = () => {
4529
4557
  if (doneRef.current) return;
4530
4558
  doneRef.current = true;
4531
- onCommit(stripEditorMarkers(ref.current?.innerText ?? '', sticky.listType), fmtRef.current);
4559
+ const el = ref.current;
4560
+ // issue-106 — the editor IS the measurement: same class, same width, same
4561
+ // font as the committed body, and laid out in world units (the pan/zoom
4562
+ // transform on `.dc-world` doesn't change layout), so its scrollHeight is
4563
+ // the height the card needs. No offscreen measuring rig required.
4564
+ onCommit(
4565
+ stripEditorMarkers(el?.innerText ?? '', sessionListType),
4566
+ fmtRef.current,
4567
+ el?.scrollHeight
4568
+ );
4532
4569
  };
4533
4570
  // FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
4534
4571
  // "done editing" (the button's onMouseDown preventDefault usually stops the
@@ -4544,10 +4581,33 @@ function StickyEditor({
4544
4581
  const y = Math.min(sticky.y, sticky.y + sticky.h);
4545
4582
  const w = Math.abs(sticky.w);
4546
4583
  const h = Math.abs(sticky.h);
4584
+ const r = sticky.cornerRadius ?? STICKY_CORNER_RADIUS;
4547
4585
  return (
4548
4586
  <div
4549
4587
  data-annot-editor="1"
4550
- style={{ position: 'absolute', left: x, top: y, width: w, height: h, zIndex: 5 }}
4588
+ style={{
4589
+ position: 'absolute',
4590
+ left: x,
4591
+ top: y,
4592
+ width: w,
4593
+ // issue-106 — NOT a fixed `height: h`. The card clips at `overflow:
4594
+ // hidden`, so on a sticky that is already full the line Shift+Enter
4595
+ // inserts landed outside the box: the keystroke worked, nothing moved
4596
+ // on screen, and it read as "shift+enter does nothing". While the
4597
+ // editor is open it grows downward instead, painting its own paper so
4598
+ // the overflow still reads as the note (the SVG card behind is still
4599
+ // the old size until the commit below persists the grown height).
4600
+ minHeight: h,
4601
+ zIndex: 5,
4602
+ // `backgroundColor`, never the `background` shorthand: `color` comes
4603
+ // verbatim from a synced stroke's `fill` (peer-controlled, and the
4604
+ // sanitizer does not touch `fill`), and the shorthand would accept a
4605
+ // `url(...)` — an outbound request from the studio origin the moment a
4606
+ // note is opened. `background-color` cannot take one.
4607
+ backgroundColor: sticky.color,
4608
+ // Mirrors stickyCornerPath: TL/TR/BL rounded, BR sharp.
4609
+ borderRadius: `${r}px ${r}px 0 ${r}px`,
4610
+ }}
4551
4611
  >
4552
4612
  <div
4553
4613
  ref={ref}
@@ -4561,6 +4621,11 @@ function StickyEditor({
4561
4621
  outline: 'none',
4562
4622
  cursor: 'text',
4563
4623
  ...CARET_FIX_STYLE,
4624
+ // Beats `.dc-sticky-body { height: 100%; overflow: hidden }` for the
4625
+ // duration of the edit — see the wrapper note above.
4626
+ height: 'auto',
4627
+ minHeight: h,
4628
+ overflow: 'visible',
4564
4629
  }}
4565
4630
  onBlur={onBlur}
4566
4631
  onPointerDown={caretHandlers.onPointerDown}
@@ -4573,6 +4638,11 @@ function StickyEditor({
4573
4638
  onCancel();
4574
4639
  return;
4575
4640
  }
4641
+ // issue-106 — see the same guard in TextEditor: Shift+Enter must add
4642
+ // a line, never consume the entry select-all and delete the body.
4643
+ if (e.key === 'Enter' && e.shiftKey && ref.current) {
4644
+ collapseEntrySelectAll(ref.current, window);
4645
+ }
4576
4646
  // Unified: plain Enter commits, Shift+Enter inserts a newline.
4577
4647
  // ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
4578
4648
  if (e.key === 'Enter' && !e.shiftKey) {
@@ -4588,7 +4658,7 @@ function StickyEditor({
4588
4658
  >
4589
4659
  {/* Show the list markers while editing (item 4c) so the read↔edit swap
4590
4660
  doesn't flicker; stripped back to raw text on commit. */}
4591
- {stickyBodyText(sticky)}
4661
+ {sessionBody}
4592
4662
  </div>
4593
4663
  </div>
4594
4664
  );
@@ -4736,6 +4806,11 @@ function StandaloneTextEditor({
4736
4806
  onCancel();
4737
4807
  return;
4738
4808
  }
4809
+ // issue-106 — same guard as the other two editors, for the multi-line
4810
+ // case only (a singleLine field commits on Shift+Enter anyway).
4811
+ if (e.key === 'Enter' && e.shiftKey && !singleLine && ref.current) {
4812
+ collapseEntrySelectAll(ref.current, window);
4813
+ }
4739
4814
  // Unified: plain Enter commits, Shift+Enter inserts a newline. A
4740
4815
  // singleLine field (section rename) is a title — Shift+Enter commits
4741
4816
  // too rather than adding a newline the one-line chip can't show.