@1agh/maude 1.2.0 → 1.3.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 (91) 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 +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -2,7 +2,7 @@
2
2
  // Returns plain objects; http.ts wraps them in Response.json().
3
3
 
4
4
  import crypto from 'node:crypto';
5
- import type { Dirent } from 'node:fs';
5
+ import { type Dirent, renameSync } from 'node:fs';
6
6
  import {
7
7
  lstat,
8
8
  mkdir,
@@ -21,6 +21,7 @@ import { rewriteRelativeImports } from './canvas-imports.ts';
21
21
  import { canvasSlugFromRel } from './canvas-slug.ts';
22
22
  import { atomicWrite } from './sync/atomic-write.ts';
23
23
  import { dedupeCommentsById } from './sync/comment-identity.ts';
24
+ import { isRuntimeStateRel } from './sync/file-membership.ts';
24
25
 
25
26
  // Re-exported so existing external callers (canvas-list-watch.ts, tests) keep
26
27
  // importing it from api.ts — the actual implementation now lives in
@@ -28,8 +29,19 @@ import { dedupeCommentsById } from './sync/comment-identity.ts';
28
29
  // without a cycle back through api.ts.
29
30
  export { canvasSlugFromRel } from './canvas-slug.ts';
30
31
 
32
+ /** Plan T17/L03 — the supporting files the file tree can move, rename and
33
+ * delete: what it previews (notes, styles, data, images, media, fonts), minus
34
+ * the canvas's own sidecars, which only ever travel with their canvas. */
35
+ export function isSupportingFileRel(rel: string): boolean {
36
+ if (/\.(meta\.json|annotations\.svg|registry\.json)$/i.test(rel)) return false;
37
+ return /\.(md|css|json|txt|ya?ml|svg|png|jpe?g|gif|webp|avif|mp4|webm|mov|mp3|wav|ogg|m4a|woff2?|ttf|otf)$/i.test(
38
+ rel
39
+ );
40
+ }
41
+
31
42
  import {
32
43
  type AssembleClip,
44
+ type AttributeState,
33
45
  assembleCompSource,
34
46
  CanvasEditError,
35
47
  type ClipInfo,
@@ -63,6 +75,7 @@ import {
63
75
  setArtboardGuides,
64
76
  setArtboardHug,
65
77
  setArtboardKind,
78
+ setArtboardLabel,
66
79
  setArtboardPrint,
67
80
  setArtboardStyle,
68
81
  toggleClipHidden,
@@ -102,6 +115,7 @@ import { clearLocatorSlug, readLocator, writeLocator } from './locator.ts';
102
115
  import { STICKERS_DIR } from './paths.ts';
103
116
  import { getPaperPreset, MAX_PRINT_MM } from './print/units.ts';
104
117
  import { sessionDir } from './session-scope.ts';
118
+ import { describeSourceOp } from './sync/source-ops.ts';
105
119
  import { isWorkspaceMode } from './workspace-mode.ts';
106
120
 
107
121
  // Directories that never hold user-facing canvases. Exported so the
@@ -127,7 +141,7 @@ const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
127
141
  // an explicit enumerated list rather than a broad pattern so it can never
128
142
  // accidentally widen to swallow runtime JSON — findFiles already excludes
129
143
  // `_`-prefixed entries before this list is even consulted.
130
- const PREVIEW_ASSET_EXTS = [
144
+ export const PREVIEW_ASSET_EXTS = [
131
145
  '.svg',
132
146
  '.png',
133
147
  '.jpg',
@@ -157,6 +171,30 @@ const PREVIEW_ASSET_EXTS = [
157
171
  * (`system/<ds>/preview/*.html`) intentionally stay `.html` and travel via
158
172
  * the DS-aware `findFiles()` path below.
159
173
  */
174
+ /**
175
+ * True when `abs` is a directory tree holding nothing but folder placeholders
176
+ * (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any
177
+ * content arrives. False when it is absent, is not a directory, or holds
178
+ * anything real (a symlink counts as real: never followed, never removed).
179
+ */
180
+ async function onlyFolderPlaceholders(abs: string, depth = 0): Promise<boolean> {
181
+ if (depth > 16) return false;
182
+ let entries: Dirent[];
183
+ try {
184
+ entries = await readdir(abs, { withFileTypes: true });
185
+ } catch {
186
+ return false;
187
+ }
188
+ for (const e of entries) {
189
+ if (e.isDirectory()) {
190
+ if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false;
191
+ } else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) {
192
+ return false;
193
+ }
194
+ }
195
+ return true;
196
+ }
197
+
160
198
  export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise<string[]> {
161
199
  const out: string[] = [];
162
200
  let entries: Dirent[];
@@ -189,7 +227,7 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
189
227
  * traversal instead of adding a second full walk of `system/` (the largest
190
228
  * group) just to enumerate directories.
191
229
  */
192
- async function findFiles(
230
+ export async function findFiles(
193
231
  absRoot: string,
194
232
  prefix: string,
195
233
  exts: string[],
@@ -312,8 +350,27 @@ export type CreateFolderResult =
312
350
 
313
351
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
314
352
  export type EditOpResult =
315
- | { ok: true; delta: number; seq?: number }
316
- | { ok: false; status: number; error: string };
353
+ /** `previous` — what a css/attr write replaced (`null` = was unset); absent
354
+ * when it was an expression no literal undo can restore. */
355
+ | { ok: true; delta: number; seq?: number; previous?: string | null }
356
+ /** `conflict` = the target no longer held the caller's expected value. */
357
+ | { ok: false; status: number; error: string; conflict?: true };
358
+
359
+ /**
360
+ * Optional expected-current value for a css/attr write (audit 2026-09-13 P1
361
+ * #5). An absent `expected` key keeps the unconditional legacy behaviour; a
362
+ * present one must be a bounded string or `null` ("currently unset").
363
+ */
364
+ function expectedValueOf(input: {
365
+ expected?: unknown;
366
+ }): { ok: true; expected?: string | null } | { ok: false } {
367
+ if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true };
368
+ if (input.expected === null) return { ok: true, expected: null };
369
+ if (typeof input.expected === 'string' && input.expected.length <= 256) {
370
+ return { ok: true, expected: input.expected };
371
+ }
372
+ return { ok: false };
373
+ }
317
374
 
318
375
  /**
319
376
  * Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
@@ -374,7 +431,9 @@ export interface Api {
374
431
  ): Promise<Record<string, unknown> | null>;
375
432
  // Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
376
433
  loadAnnotations(file: string): Promise<string | null>;
377
- saveAnnotations(file: string, svg: string): Promise<boolean>;
434
+ saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
435
+ /** Materialize a document snapshot without publishing it as another user edit. */
436
+ projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
378
437
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
379
438
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
380
439
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
@@ -425,6 +484,10 @@ export interface Api {
425
484
  width?: unknown;
426
485
  height?: unknown;
427
486
  }): Promise<CreateCanvasResult>;
487
+ // Duplicate a canvas beside itself ("<name> copy") — POST /_api/canvas
488
+ // { duplicateOf }. Source, meta and the whiteboard layer; not comments or
489
+ // history, which belong to the original.
490
+ duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult>;
428
491
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
429
492
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
430
493
  // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
@@ -442,6 +505,8 @@ export interface Api {
442
505
  value?: unknown;
443
506
  reset?: unknown;
444
507
  idIndex?: unknown;
508
+ /** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
509
+ expected?: unknown;
445
510
  }): Promise<EditOpResult>;
446
511
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
447
512
  editText(input: {
@@ -459,6 +524,8 @@ export interface Api {
459
524
  id?: unknown;
460
525
  attr?: unknown;
461
526
  value?: unknown;
527
+ reset?: unknown;
528
+ expected?: unknown;
462
529
  }): Promise<EditOpResult>;
463
530
  // Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
464
531
  // only. Moves the element with data-cd-id `id` to `position` relative to
@@ -662,6 +729,12 @@ export interface Api {
662
729
  artboardId?: unknown;
663
730
  kind?: unknown;
664
731
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
732
+ /** Rename an artboard (T25/L08 — double-click its name on the canvas). */
733
+ setArtboardLabelOp(input: {
734
+ canvas?: unknown;
735
+ artboardId?: unknown;
736
+ label?: unknown;
737
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
665
738
  /** Generic layout guides (T5) — replace-whole-prop write. */
666
739
  setArtboardGuidesOp(input: {
667
740
  canvas?: unknown;
@@ -736,9 +809,26 @@ export interface ApiHooks {
736
809
  * stays". Passing the list removes the read, and `publishComments` awaits
737
810
  * this hook BEFORE touching disk so the doc is never the stale side.
738
811
  */
739
- onCommentsChanged: (file: string, comments: Comment[]) => void | Promise<void>;
812
+ /** `base` — the list this mutation started from (a merge hint for the project). */
813
+ onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
740
814
  /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
741
- onAnnotationsChanged?: (file: string, svg: string) => void;
815
+ onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
816
+ /**
817
+ * Accepted-revisions mode (DDR-241): propose a folder operation as ONE
818
+ * project action before touching disk. Absent, or answering `null`, means
819
+ * the project is not in that mode and the local operation is the whole story.
820
+ */
821
+ /**
822
+ * A layout (shared meta) write, with the file text it replaced — accepted
823
+ * revisions propose it with that base instead of inferring one.
824
+ */
825
+ onMetaChanged?: (file: string, text: string, baseText: string | null) => void;
826
+ proposeFolder?: (
827
+ op:
828
+ | { op: 'dir.create'; path: string }
829
+ | { op: 'dir.delete'; path: string }
830
+ | { op: 'dir.move'; from: string; to: string }
831
+ ) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
742
832
  /**
743
833
  * feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
744
834
  * (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
@@ -1009,7 +1099,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1009
1099
  // stores in its undo record. Ephemeral by design: a server restart drops it
1010
1100
  // (undo answers 404 and the canvas stack entry is a no-op, honest failure).
1011
1101
  const REORDER_LOG_CAP = 50;
1012
- const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
1102
+ const reorderLog = new Map<
1103
+ number,
1104
+ { abs: string; before: string; after: string; undoActionId?: string }
1105
+ >();
1013
1106
  let reorderSeq = 0;
1014
1107
 
1015
1108
  // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
@@ -1187,9 +1280,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1187
1280
  * content — otherwise the file→doc import that follows the write would see a
1188
1281
  * difference and re-enter the loop.
1189
1282
  */
1190
- async function publishComments(file: string, list: Comment[]): Promise<void> {
1283
+ async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
1191
1284
  const settled = dedupeCommentsById(list);
1192
- await onCommentsChanged(file, settled);
1285
+ await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
1193
1286
  await saveCommentsForFile(file, settled);
1194
1287
  }
1195
1288
 
@@ -1321,6 +1414,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1321
1414
  if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
1322
1415
  if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
1323
1416
  const list = await loadCommentsForFile(payload.file);
1417
+ const base = structuredClone(list);
1324
1418
  const text = String(payload.text).trim().slice(0, 4000);
1325
1419
  const author =
1326
1420
  typeof payload.author === 'string' && payload.author.trim()
@@ -1369,7 +1463,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1369
1463
  if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
1370
1464
  }
1371
1465
  list.push(c);
1372
- await publishComments(payload.file, list);
1466
+ await publishComments(payload.file, list, base);
1373
1467
  return c;
1374
1468
  }
1375
1469
 
@@ -1384,6 +1478,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1384
1478
  if (i < 0) continue;
1385
1479
  const entry = list[i];
1386
1480
  if (!entry) continue;
1481
+ const base = structuredClone(list);
1387
1482
  const body = payload.body.trim().slice(0, 4000);
1388
1483
  const author =
1389
1484
  typeof payload.author === 'string' && payload.author.trim()
@@ -1397,7 +1492,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1397
1492
  };
1398
1493
  entry.thread = [...entry.thread, reply];
1399
1494
  entry.mentions = mentionsUnion(entry);
1400
- await publishComments(file, list);
1495
+ await publishComments(file, list, base);
1401
1496
  return entry;
1402
1497
  }
1403
1498
  return null;
@@ -1417,6 +1512,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1417
1512
  const matches = list.filter((c) => c.id === id);
1418
1513
  const first = matches[0];
1419
1514
  if (!first) continue;
1515
+ const base = structuredClone(list);
1420
1516
  for (const entry of matches) {
1421
1517
  if (patch.status === 'resolved' || patch.status === 'open') {
1422
1518
  entry.status = patch.status;
@@ -1427,7 +1523,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1427
1523
  entry.mentions = mentionsUnion(entry);
1428
1524
  }
1429
1525
  }
1430
- await publishComments(file, list);
1526
+ await publishComments(file, list, base);
1431
1527
  return first;
1432
1528
  }
1433
1529
  return null;
@@ -1438,7 +1534,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1438
1534
  for (const [file, list] of Object.entries(all)) {
1439
1535
  const remaining = list.filter((c) => c.id !== id);
1440
1536
  if (remaining.length === list.length) continue;
1441
- await publishComments(file, remaining);
1537
+ await publishComments(file, remaining, list);
1442
1538
  return true;
1443
1539
  }
1444
1540
  return false;
@@ -1946,7 +2042,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1946
2042
  next.last_modified = new Date().toISOString();
1947
2043
  // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
1948
2044
  // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
1949
- await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
2045
+ const baseText = Object.keys(current).length ? JSON.stringify(current) : null;
2046
+ const nextText = `${JSON.stringify(next, null, 2)}\n`;
2047
+ await Bun.write(metaAbs, nextText);
2048
+ hooks.onMetaChanged?.(file, nextText, baseText);
1950
2049
  // Same reason as the annotations sidecar below, and the same bug: this
1951
2050
  // lane writes the FILE and nothing else, so in a cell — where there is no
1952
2051
  // `fs.watch` — the layout never entered the doc and never reached a peer.
@@ -1986,7 +2085,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1986
2085
  }
1987
2086
  }
1988
2087
 
1989
- async function saveAnnotations(file: string, svg: string): Promise<boolean> {
2088
+ async function saveAnnotations(
2089
+ file: string,
2090
+ svg: string,
2091
+ writeId?: string,
2092
+ base?: string
2093
+ ): Promise<boolean> {
1990
2094
  if (typeof svg !== 'string') return false;
1991
2095
  if (svg.length > 1024 * 1024) return false;
1992
2096
  // Cheap content gate — must look like an <svg> document. Avoids accidental
@@ -2004,8 +2108,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2004
2108
  // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
2005
2109
  // polyline/text — so stripping executable constructs is zero-regression.
2006
2110
  const clean = sanitizeAnnotationSvg(svg);
2111
+ // The edit's base travels only as a merge hint for the project — bounded
2112
+ // and sanitized like the value itself, never written anywhere.
2113
+ const cleanBase =
2114
+ typeof base === 'string' &&
2115
+ base.length <= 1024 * 1024 &&
2116
+ (base === '' || /^\s*<svg[\s>]/i.test(base))
2117
+ ? base === ''
2118
+ ? ''
2119
+ : sanitizeAnnotationSvg(base)
2120
+ : undefined;
2007
2121
  await Bun.write(annotationsPath(file), clean);
2008
- onAnnotationsChanged?.(file, clean);
2122
+ onAnnotationsChanged?.(file, clean, writeId, cleanBase);
2009
2123
  // Annotations reach OTHER VIEWERS over the collab room, which is why this
2010
2124
  // never needed an `fs:any`. But the file is also a versioned, file-plane
2011
2125
  // sidecar (DDR-115), and the file plane learns about a cell's own writes
@@ -2016,6 +2130,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2016
2130
  return true;
2017
2131
  }
2018
2132
 
2133
+ async function projectAnnotations(
2134
+ file: string,
2135
+ svg: string,
2136
+ isCurrent: () => boolean
2137
+ ): Promise<boolean> {
2138
+ if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
2139
+ return false;
2140
+ if (!isCurrent()) return false;
2141
+ const clean = sanitizeAnnotationSvg(svg);
2142
+ // Runtime scratch stays out of the file plane. The async IO must not touch
2143
+ // the serving file until we recheck the document; another edit may have
2144
+ // arrived while Bun.write was pending. Check + rename have no await gap.
2145
+ const scratch = path.join(paths.designRoot, '_state');
2146
+ await mkdir(scratch, { recursive: true });
2147
+ const temp = path.join(scratch, `annotations-${crypto.randomUUID()}.tmp`);
2148
+ try {
2149
+ await Bun.write(temp, clean);
2150
+ if (!isCurrent()) return false;
2151
+ renameSync(temp, annotationsPath(file));
2152
+ announceWritten(`${fileSlug(file)}.annotations.svg`);
2153
+ return true;
2154
+ } finally {
2155
+ await rm(temp, { force: true });
2156
+ }
2157
+ }
2158
+
2019
2159
  // Phase 23 — content-addressed asset write. Reachable from the (potentially
2020
2160
  // untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
2021
2161
  // optional (DDR Task 9):
@@ -2754,6 +2894,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2754
2894
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
2755
2895
  }
2756
2896
 
2897
+ async function duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult> {
2898
+ const raw = input?.file;
2899
+ if (typeof raw !== 'string' || !raw.trim()) {
2900
+ return { ok: false, status: 400, error: 'file is required' };
2901
+ }
2902
+ let rel = raw.trim();
2903
+ try {
2904
+ rel = decodeURIComponent(rel);
2905
+ } catch {
2906
+ /* leave as-is */
2907
+ }
2908
+ rel = rel.replace(/^\/+/, '');
2909
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2910
+ if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
2911
+ if (rel.includes('..') || !/\.tsx$/i.test(rel)) {
2912
+ return { ok: false, status: 400, error: 'invalid path' };
2913
+ }
2914
+ const fileAbs = path.resolve(path.join(paths.designRoot, rel));
2915
+ const dirAbs = path.dirname(fileAbs);
2916
+ const groups = cfg.canvasGroups.filter(
2917
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2918
+ );
2919
+ const inGroup = groups.some((g) => {
2920
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2921
+ return dirAbs === gAbs || dirAbs.startsWith(`${gAbs}${path.sep}`);
2922
+ });
2923
+ if (!inGroup) {
2924
+ return {
2925
+ ok: false,
2926
+ status: 400,
2927
+ error: 'only canvases under a managed canvas group can be duplicated',
2928
+ };
2929
+ }
2930
+ if (!(await assertRealpathContained(dirAbs))) {
2931
+ return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
2932
+ }
2933
+ if (!(await Bun.file(fileAbs).exists())) {
2934
+ return { ok: false, status: 404, error: 'canvas not found' };
2935
+ }
2936
+ const base = path.basename(rel).replace(/\.tsx$/i, '');
2937
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
2938
+ // "<name> copy", then "<name> copy 2", … — the first free, valid name.
2939
+ let name: string | null = null;
2940
+ for (let n = 1; n < 100 && !name; n++) {
2941
+ const suffix = n === 1 ? ' copy' : ` copy ${n}`;
2942
+ const v = validateCanvasName(`${base.slice(0, 60 - suffix.length)}${suffix}`);
2943
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
2944
+ const candidate = path.posix.join(dir, `${v.name}.tsx`);
2945
+ if (await Bun.file(path.join(paths.designRoot, candidate)).exists()) continue;
2946
+ if (await fileForSlug(fileSlug(candidate))) continue;
2947
+ name = v.name;
2948
+ }
2949
+ if (!name) return { ok: false, status: 409, error: 'too many copies of this canvas' };
2950
+ const toRel = path.posix.join(dir, `${name}.tsx`);
2951
+ const toAbs = path.join(paths.designRoot, toRel);
2952
+ await Bun.write(toAbs, await Bun.file(fileAbs).arrayBuffer());
2953
+ const metaAbs = fileAbs.replace(/\.tsx$/i, '.meta.json');
2954
+ if (await Bun.file(metaAbs).exists()) {
2955
+ let text = await Bun.file(metaAbs).text();
2956
+ try {
2957
+ const meta = JSON.parse(text) as Record<string, unknown>;
2958
+ const now = new Date().toISOString();
2959
+ if (typeof meta.title !== 'string' || meta.title === base) meta.title = name;
2960
+ meta.created = now;
2961
+ meta.last_modified = now;
2962
+ text = `${JSON.stringify(meta, null, 2)}\n`;
2963
+ } catch {
2964
+ /* an unreadable meta is copied verbatim, like the source */
2965
+ }
2966
+ await Bun.write(toAbs.replace(/\.tsx$/i, '.meta.json'), text);
2967
+ }
2968
+ const slug = fileSlug(toRel);
2969
+ // The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
2970
+ const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
2971
+ if (await Bun.file(annotationsAbs).exists()) {
2972
+ await Bun.write(
2973
+ path.join(paths.designRoot, `${slug}.annotations.svg`),
2974
+ await Bun.file(annotationsAbs).arrayBuffer()
2975
+ );
2976
+ }
2977
+ ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
2978
+ ctx.bus.emit('canvas-created', { slug });
2979
+ return { ok: true, file: path.posix.join(paths.designRel, toRel), rel: toRel, slug };
2980
+ }
2981
+
2757
2982
  // Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
2758
2983
  // createCanvas: main-origin-only, never the untrusted canvas iframe origin
2759
2984
  // (DDR-054). Destructive, so it MOVES the whole sidecar set to
@@ -2777,6 +3002,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2777
3002
 
2778
3003
  // Only a real `.tsx` canvas, no traversal.
2779
3004
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3005
+ if (
3006
+ !/\.tsx$/i.test(rel) &&
3007
+ isSupportingFileRel(rel) &&
3008
+ (await isRegularFile(path.join(paths.designRoot, rel)))
3009
+ ) {
3010
+ return deleteSupportingFile(rel);
3011
+ }
2780
3012
  if (!/\.tsx$/i.test(rel)) {
2781
3013
  // feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
2782
3014
  // a folder delete (dogfood gap: the tree offered no way to remove a
@@ -2886,6 +3118,176 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2886
3118
  // itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
2887
3119
  // is recoverable exactly like a single-canvas delete), then best-effort
2888
3120
  // removes whatever's left (empty subdirs, `.gitkeep` markers).
3121
+ // ── Plan T17/L03 — supporting files ──────────────────────────────────────
3122
+ // Notes, styles, data, images, media and fonts that live in a canvas folder
3123
+ // beside the canvases. They move, rename and delete as themselves. One that a
3124
+ // canvas, stylesheet or whiteboard still names is refused with the name of
3125
+ // the file that uses it: a moved or deleted asset must never leave a broken
3126
+ // image behind silently (L17).
3127
+ async function isRegularFile(abs: string): Promise<boolean> {
3128
+ try {
3129
+ return (await statp(abs)).isFile();
3130
+ } catch {
3131
+ return false;
3132
+ }
3133
+ }
3134
+
3135
+ async function supportingFileGuard(
3136
+ rel: string
3137
+ ): Promise<{ ok: true; abs: string } | { ok: false; status: number; error: string }> {
3138
+ if (rel.includes('..') || rel.startsWith('/') || isRuntimeStateRel(rel)) {
3139
+ return { ok: false, status: 400, error: 'invalid path' };
3140
+ }
3141
+ const abs = path.resolve(path.join(paths.designRoot, rel));
3142
+ const groups = cfg.canvasGroups.filter(
3143
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
3144
+ );
3145
+ const inGroup = groups.some((g) =>
3146
+ abs.startsWith(`${path.resolve(path.join(paths.designRoot, g.path))}${path.sep}`)
3147
+ );
3148
+ if (!inGroup) {
3149
+ return {
3150
+ ok: false,
3151
+ status: 400,
3152
+ error: 'only files inside a canvas folder can be changed here',
3153
+ };
3154
+ }
3155
+ if (!(await assertRealpathContained(path.dirname(abs)))) {
3156
+ return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
3157
+ }
3158
+ if (!(await isRegularFile(abs))) return { ok: false, status: 404, error: 'file not found' };
3159
+ return { ok: true, abs };
3160
+ }
3161
+
3162
+ /** The first project file (canvas, stylesheet, meta, whiteboard) that names `rel`'s file. */
3163
+ async function firstReferenceTo(rel: string): Promise<string | null> {
3164
+ const base = path.posix.basename(rel);
3165
+ const { readdir, readFile } = await import('node:fs/promises');
3166
+ const walk = async (dirAbs: string, depth: number): Promise<string | null> => {
3167
+ if (depth > 12) return null;
3168
+ let entries: import('node:fs').Dirent[];
3169
+ try {
3170
+ entries = await readdir(dirAbs, { withFileTypes: true });
3171
+ } catch {
3172
+ return null;
3173
+ }
3174
+ for (const e of entries) {
3175
+ if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'node_modules') continue;
3176
+ const abs = path.join(dirAbs, e.name);
3177
+ if (e.isDirectory()) {
3178
+ const hit = await walk(abs, depth + 1);
3179
+ if (hit) return hit;
3180
+ } else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
3181
+ const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
3182
+ if (other === rel) continue;
3183
+ const text = await readFile(abs, 'utf8').catch(() => '');
3184
+ if (text.includes(base)) return other;
3185
+ }
3186
+ }
3187
+ return null;
3188
+ };
3189
+ return walk(paths.designRoot, 0);
3190
+ }
3191
+
3192
+ async function moveSupportingFile(
3193
+ rel: string,
3194
+ toDirRaw: unknown,
3195
+ toNameRaw: unknown
3196
+ ): Promise<MoveCanvasResult> {
3197
+ const g = await supportingFileGuard(rel);
3198
+ if (!g.ok) return g;
3199
+ const ext = path.posix.extname(rel);
3200
+ let base = path.posix.basename(rel);
3201
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3202
+ const stem = String(toNameRaw).replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '');
3203
+ const v = validateCanvasName(stem);
3204
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3205
+ base = `${v.name}${ext}`;
3206
+ }
3207
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
3208
+ let toDir =
3209
+ typeof toDirRaw === 'string'
3210
+ ? toDirRaw.trim().replace(/^\/+|\/+$/g, '')
3211
+ : path.posix.dirname(rel);
3212
+ if (toDir === drPrefix || toDir === '.') toDir = '';
3213
+ else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
3214
+ const toRel = path.posix.join(toDir, base);
3215
+ if (toRel === rel)
3216
+ return { ok: false, status: 400, error: 'source and destination are the same' };
3217
+ const dest = await supportingFileGuard(toRel).then((d) =>
3218
+ d.ok
3219
+ ? { ok: false as const, status: 409, error: `a file named "${base}" already exists there` }
3220
+ : d
3221
+ );
3222
+ if (!dest.ok && dest.status !== 404) return dest;
3223
+ const toAbs = path.resolve(path.join(paths.designRoot, toRel));
3224
+ if (!(await isRegularFile(path.join(paths.designRoot, rel))))
3225
+ return { ok: false, status: 404, error: 'file not found' };
3226
+ const toDirAbs = path.dirname(toAbs);
3227
+ try {
3228
+ if (!(await statp(toDirAbs)).isDirectory()) throw new Error('not a folder');
3229
+ } catch {
3230
+ return { ok: false, status: 400, error: 'destination folder does not exist' };
3231
+ }
3232
+ const user = await firstReferenceTo(rel);
3233
+ if (user) {
3234
+ return {
3235
+ ok: false,
3236
+ status: 409,
3237
+ error: `${path.posix.basename(rel)} is used by ${user} — change that first`,
3238
+ };
3239
+ }
3240
+ await rename(g.abs, toAbs);
3241
+ // A hub's own studio has no watcher to see this rename: announce both
3242
+ // ends, or the journal learns of it only from the walk-import belt and a
3243
+ // teammate waits minutes for a rename that took a second here.
3244
+ announceWritten(rel);
3245
+ announceWritten(toRel);
3246
+ ctx.bus.emit('canvas-list-update', {
3247
+ action: 'moved',
3248
+ rel: toRel,
3249
+ slug: fileSlug(toRel),
3250
+ fromRel: rel,
3251
+ fromSlug: fileSlug(rel),
3252
+ });
3253
+ return {
3254
+ ok: true,
3255
+ fromRel: rel,
3256
+ toRel,
3257
+ fromSlug: fileSlug(rel),
3258
+ toSlug: fileSlug(toRel),
3259
+ moved: [toRel],
3260
+ };
3261
+ }
3262
+
3263
+ async function deleteSupportingFile(rel: string): Promise<DeleteCanvasResult> {
3264
+ const g = await supportingFileGuard(rel);
3265
+ if (!g.ok) return g;
3266
+ const user = await firstReferenceTo(rel);
3267
+ if (user) {
3268
+ return {
3269
+ ok: false,
3270
+ status: 409,
3271
+ error: `${path.posix.basename(rel)} is used by ${user} — remove it there first`,
3272
+ };
3273
+ }
3274
+ const slug = fileSlug(rel);
3275
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3276
+ const trashDir = path.join(paths.designRoot, '_trash', `${stamp}__${slug}`);
3277
+ await mkdir(trashDir, { recursive: true });
3278
+ const trashed = path.join(trashDir, path.posix.basename(rel));
3279
+ await rename(g.abs, trashed);
3280
+ announceWritten(rel);
3281
+ ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
3282
+ return {
3283
+ ok: true,
3284
+ rel,
3285
+ slug,
3286
+ trashed: [path.relative(paths.repoRoot, trashed)],
3287
+ trashDir: path.relative(paths.repoRoot, trashDir),
3288
+ };
3289
+ }
3290
+
2889
3291
  async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
2890
3292
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2891
3293
  const dirAbs = path.join(paths.designRoot, relDir);
@@ -2942,6 +3344,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2942
3344
  p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
2943
3345
  );
2944
3346
 
3347
+ // ONE project action for the whole folder (accepted-revisions mode): the
3348
+ // hub deletes every canvas under it and the folder entry together, so a
3349
+ // peer never sees half a folder, and history shows one step.
3350
+ const folderAction = await hooks.proposeFolder?.({
3351
+ op: 'dir.delete',
3352
+ path: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3353
+ });
3354
+ if (folderAction?.status === 'rejected') {
3355
+ return {
3356
+ ok: false,
3357
+ status: 409,
3358
+ error: `the project did not accept deleting this folder (${folderAction.code ?? 'rejected'})`,
3359
+ };
3360
+ }
3361
+
2945
3362
  const trashed: string[] = [];
2946
3363
  let lastTrashDir = '';
2947
3364
  for (const r of canvasRels) {
@@ -3051,7 +3468,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3051
3468
  // never wrong about where the canvas lives) and every relocation is logged
3052
3469
  // to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
3053
3470
  // (Task 14) for the accepted-limitation writeup.
3054
- async function moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise<MoveCanvasResult> {
3471
+ async function moveCanvas(input: {
3472
+ file?: unknown;
3473
+ toDir?: unknown;
3474
+ toName?: unknown;
3475
+ }): Promise<MoveCanvasResult> {
3055
3476
  const raw = input?.file;
3056
3477
  if (typeof raw !== 'string' || !raw.trim()) {
3057
3478
  return { ok: false, status: 400, error: 'file is required' };
@@ -3067,17 +3488,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3067
3488
  if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
3068
3489
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3069
3490
  if (!/\.tsx$/i.test(rel)) {
3491
+ // Plan T17/L03 — a supporting file (notes, styles, images, media in a
3492
+ // canvas folder) moves or renames as itself, never as a "folder".
3493
+ if (isSupportingFileRel(rel) && (await isRegularFile(path.join(paths.designRoot, rel)))) {
3494
+ return moveSupportingFile(rel, input?.toDir, input?.toName);
3495
+ }
3070
3496
  // feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
3071
3497
  // folder move (dragging a folder onto a folder). moveFolder does its own
3072
3498
  // existence + containment validation, so an invalid path still reports
3073
3499
  // the right error from there.
3074
- return moveFolder(rel, input?.toDir);
3075
- }
3076
-
3077
- if (typeof input?.toDir !== 'string') {
3500
+ return moveFolder(rel, input?.toDir, input?.toName);
3501
+ }
3502
+
3503
+ // A rename is a move in place under a new name (`toName`, same allowlist as
3504
+ // a new canvas's name); `toDir` defaults to the canvas's own folder then.
3505
+ let newBase: string | null = null;
3506
+ if (input?.toName !== undefined && input.toName !== null && input.toName !== '') {
3507
+ const v = validateCanvasName(input.toName);
3508
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3509
+ newBase = `${v.name}.tsx`;
3510
+ }
3511
+ const toDirInput =
3512
+ typeof input?.toDir === 'string'
3513
+ ? input.toDir
3514
+ : newBase
3515
+ ? path.posix.dirname(rel) === '.'
3516
+ ? ''
3517
+ : path.posix.dirname(rel)
3518
+ : null;
3519
+ if (typeof toDirInput !== 'string') {
3078
3520
  return { ok: false, status: 400, error: 'toDir is required' };
3079
3521
  }
3080
- let toDir = input.toDir.trim().replace(/^\/+|\/+$/g, '');
3522
+ let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
3081
3523
  try {
3082
3524
  toDir = decodeURIComponent(toDir);
3083
3525
  } catch {
@@ -3145,7 +3587,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3145
3587
  return { ok: false, status: 404, error: 'canvas not found' };
3146
3588
  }
3147
3589
 
3148
- const base = path.basename(rel);
3590
+ const base = newBase ?? path.basename(rel);
3149
3591
  const toRel = path.posix.join(toDir, base);
3150
3592
  if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
3151
3593
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3313,7 +3755,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3313
3755
  // nested canvas at once (they live inside the moved directory); only the
3314
3756
  // slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
3315
3757
  // folder structure) need their own per-canvas relocation afterward.
3316
- async function moveFolder(relDir: string, toDirRaw: unknown): Promise<MoveCanvasResult> {
3758
+ async function moveFolder(
3759
+ relDir: string,
3760
+ toDirRaw: unknown,
3761
+ toNameRaw?: unknown
3762
+ ): Promise<MoveCanvasResult> {
3317
3763
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3318
3764
  const dirAbs = path.join(paths.designRoot, relDir);
3319
3765
  const resolvedDesignRoot = path.resolve(paths.designRoot);
@@ -3389,7 +3835,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3389
3835
  return { ok: false, status: 400, error: 'cannot move a folder into itself' };
3390
3836
  }
3391
3837
 
3392
- const base = path.basename(relDir);
3838
+ // A RENAME is a move to a new name in the same (or another) parent — one
3839
+ // manifest action (dir.move), the same as any folder move (plan T17).
3840
+ let base = path.basename(relDir);
3841
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3842
+ const v = validateFolderName(toNameRaw);
3843
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3844
+ base = v.name;
3845
+ }
3393
3846
  const toRelDir = path.posix.join(toDir, base);
3394
3847
  if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
3395
3848
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3443,6 +3896,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3443
3896
  }
3444
3897
  }
3445
3898
 
3899
+ // ONE project action for the whole folder (accepted-revisions mode) —
3900
+ // canvases, empty sub-folders and all. The per-canvas retire below then
3901
+ // only lets go locally; it proposes nothing of its own.
3902
+ const folderMove = await hooks.proposeFolder?.({
3903
+ op: 'dir.move',
3904
+ from: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3905
+ to: toRelDir,
3906
+ });
3907
+ if (folderMove?.status === 'rejected') {
3908
+ return {
3909
+ ok: false,
3910
+ status: 409,
3911
+ error: `the project did not accept this move (${folderMove.code ?? 'rejected'})`,
3912
+ };
3913
+ }
3914
+
3446
3915
  // Collab guard for every canvas found, BEFORE any disk mutation. Same
3447
3916
  // coordinated retire as moveCanvas — refuse only when a pinned room could
3448
3917
  // not be retired, and retire even unpinned synced documents so none of
@@ -3464,6 +3933,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3464
3933
  }
3465
3934
 
3466
3935
  await mkdir(toDirAbs, { recursive: true });
3936
+ // OUR OWN ACTION MAY ALREADY BE ON THIS DISK. The destination did not exist
3937
+ // when this move was checked; the project accepted the move since, and its
3938
+ // announcement can reach this machine's sync before the answer reaches this
3939
+ // request — which materializes the destination folders (each with its
3940
+ // `.gitkeep`). Renaming onto that non-empty placeholder failed (ENOTEMPTY),
3941
+ // leaving the old folder in this person's tree while every teammate had
3942
+ // the move (plan T31/L02). A destination holding nothing but placeholders
3943
+ // is that projection; it is replaced, never merged into.
3944
+ if (await onlyFolderPlaceholders(toDirFinalAbs)) {
3945
+ await rm(toDirFinalAbs, { recursive: true, force: true });
3946
+ }
3467
3947
  try {
3468
3948
  await rename(dirAbs, toDirFinalAbs);
3469
3949
  } catch (err) {
@@ -3612,6 +4092,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3612
4092
  /* good — doesn't exist yet */
3613
4093
  }
3614
4094
 
4095
+ const folderCreate = await hooks.proposeFolder?.({
4096
+ op: 'dir.create',
4097
+ path: path.posix.join(parent, v.name),
4098
+ });
4099
+ if (folderCreate?.status === 'rejected') {
4100
+ return {
4101
+ ok: false,
4102
+ status: 409,
4103
+ error: `the project did not accept this folder (${folderCreate.code ?? 'rejected'})`,
4104
+ };
4105
+ }
4106
+
3615
4107
  await mkdir(dirAbs, { recursive: true });
3616
4108
  await Bun.write(path.join(dirAbs, '.gitkeep'), '');
3617
4109
 
@@ -3689,10 +4181,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3689
4181
  // suppression BEFORE the write so it catches the debounced fs:any, disarm on
3690
4182
  // a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
3691
4183
  // genuine agent edit's rim.
4184
+ /** T24/T25 — say what a structural edit IS (sync/source-ops), for replay. */
4185
+ function announceOp(
4186
+ abs: string,
4187
+ rel: string,
4188
+ before: string,
4189
+ op: Parameters<typeof describeSourceOp>[2]
4190
+ ): void {
4191
+ const described = describeSourceOp(abs, before, op);
4192
+ if (described) ctx.bus.emit('source-op', { rel, op: described });
4193
+ }
4194
+
3692
4195
  async function suppressedEdit(
3693
4196
  abs: string,
3694
- run: () => Promise<{ delta: number; changed?: boolean }>,
3695
- errLabel: string
4197
+ run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
4198
+ errLabel: string,
4199
+ describe?: Parameters<typeof describeSourceOp>[2]
3696
4200
  ): Promise<EditOpResult> {
3697
4201
  const rel = path.relative(paths.designRoot, abs);
3698
4202
  ctx.bus.emit('activity:suppress', rel);
@@ -3701,18 +4205,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3701
4205
  // whole-file undo log (the Timeline replace-src path rides this; other
3702
4206
  // inspector edits get an undoable seq for free).
3703
4207
  const before = await Bun.file(abs).text();
4208
+ // T24 — what this edit IS, so a lost race re-applies it instead of
4209
+ // turning "we both picked a colour" into a conflict.
4210
+ if (describe) {
4211
+ const op = describeSourceOp(abs, before, describe);
4212
+ if (op) ctx.bus.emit('source-op', { rel, op });
4213
+ }
3704
4214
  const res = await run();
4215
+ const previous =
4216
+ res.previous?.kind === 'absent'
4217
+ ? { previous: null }
4218
+ : res.previous?.kind === 'literal'
4219
+ ? { previous: res.previous.value }
4220
+ : {};
3705
4221
  // `changed === false` = the op collapsed to a no-op and nothing hit disk —
3706
4222
  // NOT `delta === 0`, which an equal-length replacement also produces.
3707
4223
  if (res.changed === false) {
3708
4224
  ctx.bus.emit('activity:unsuppress', rel);
3709
- return { ok: true, delta: res.delta };
4225
+ return { ok: true, delta: res.delta, ...previous };
3710
4226
  }
3711
4227
  const after = await Bun.file(abs).text();
3712
4228
  const seq = after !== before ? logUndo(abs, before, after) : undefined;
3713
- return { ok: true, delta: res.delta, seq };
4229
+ return { ok: true, delta: res.delta, seq, ...previous };
3714
4230
  } catch (err) {
3715
4231
  ctx.bus.emit('activity:unsuppress', rel);
4232
+ if (err instanceof CanvasEditError && err.conflict) {
4233
+ return { ok: false, status: 409, error: err.message, conflict: true };
4234
+ }
3716
4235
  return {
3717
4236
  ok: false,
3718
4237
  status: 422,
@@ -3728,9 +4247,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3728
4247
  value?: unknown;
3729
4248
  reset?: unknown;
3730
4249
  idIndex?: unknown;
4250
+ expected?: unknown;
3731
4251
  }): Promise<EditOpResult> {
3732
4252
  const r = resolveCanvasAbs(input.canvas);
3733
4253
  if (!r.ok) return r;
4254
+ const pre = expectedValueOf(input);
4255
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3734
4256
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3735
4257
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3736
4258
  // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
@@ -3755,8 +4277,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3755
4277
  if (input.reset === true) {
3756
4278
  return suppressedEdit(
3757
4279
  r.abs,
3758
- () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
3759
- 'reset failed'
4280
+ () =>
4281
+ removeAttribute(
4282
+ r.abs,
4283
+ id,
4284
+ `style.${camel}`,
4285
+ idIndex,
4286
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4287
+ ),
4288
+ 'reset failed',
4289
+ {
4290
+ kind: 'remove',
4291
+ id,
4292
+ attr: `style.${camel}`,
4293
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4294
+ }
3760
4295
  );
3761
4296
  }
3762
4297
  const value = typeof input.value === 'string' ? input.value : '';
@@ -3768,8 +4303,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3768
4303
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
3769
4304
  return suppressedEdit(
3770
4305
  r.abs,
3771
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
3772
- 'edit failed'
4306
+ () =>
4307
+ editAttribute(
4308
+ r.abs,
4309
+ id,
4310
+ `style.${camel}`,
4311
+ JSON.stringify(value),
4312
+ idIndex,
4313
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4314
+ ),
4315
+ 'edit failed',
4316
+ {
4317
+ kind: 'set',
4318
+ id,
4319
+ attr: `style.${camel}`,
4320
+ value: JSON.stringify(value),
4321
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4322
+ }
3773
4323
  );
3774
4324
  }
3775
4325
 
@@ -3802,7 +4352,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3802
4352
  return suppressedEdit(
3803
4353
  r.abs,
3804
4354
  () => runEditText(r.abs, id, text, { occurrence, before }),
3805
- 'edit failed'
4355
+ 'edit failed',
4356
+ { kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
3806
4357
  );
3807
4358
  }
3808
4359
 
@@ -3812,9 +4363,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3812
4363
  attr?: unknown;
3813
4364
  value?: unknown;
3814
4365
  reset?: unknown;
4366
+ expected?: unknown;
3815
4367
  }): Promise<EditOpResult> {
3816
4368
  const r = resolveCanvasAbs(input.canvas);
3817
4369
  if (!r.ok) return r;
4370
+ const pre = expectedValueOf(input);
4371
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3818
4372
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3819
4373
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3820
4374
  const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
@@ -3832,7 +4386,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3832
4386
  }
3833
4387
  // Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
3834
4388
  if (input.reset === true) {
3835
- return suppressedEdit(r.abs, () => removeAttribute(r.abs, id, attr), 'reset failed');
4389
+ return suppressedEdit(
4390
+ r.abs,
4391
+ () =>
4392
+ removeAttribute(
4393
+ r.abs,
4394
+ id,
4395
+ attr,
4396
+ undefined,
4397
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4398
+ ),
4399
+ 'reset failed',
4400
+ { kind: 'remove', id, attr }
4401
+ );
3836
4402
  }
3837
4403
  const value = typeof input.value === 'string' ? input.value : '';
3838
4404
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
@@ -3853,7 +4419,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3853
4419
  // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
3854
4420
  // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
3855
4421
  // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
3856
- return suppressedEdit(r.abs, () => editAttribute(r.abs, id, attr, value), 'edit failed');
4422
+ return suppressedEdit(
4423
+ r.abs,
4424
+ () =>
4425
+ editAttribute(
4426
+ r.abs,
4427
+ id,
4428
+ attr,
4429
+ value,
4430
+ undefined,
4431
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4432
+ ),
4433
+ 'edit failed',
4434
+ { kind: 'set', id, attr, value }
4435
+ );
3857
4436
  }
3858
4437
 
3859
4438
  // Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
@@ -3900,6 +4479,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3900
4479
  ctx.bus.emit('activity:suppress', rel);
3901
4480
  try {
3902
4481
  const before = await Bun.file(r.abs).text();
4482
+ announceOp(r.abs, rel, before, {
4483
+ kind: 'move',
4484
+ id,
4485
+ refId,
4486
+ position: String(position),
4487
+ ...(idIndex !== undefined ? { idIndex } : {}),
4488
+ ...(refIndex !== undefined ? { refIndex } : {}),
4489
+ });
3903
4490
  const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
3904
4491
  const after = await Bun.file(r.abs).text();
3905
4492
  if (after === before) {
@@ -4315,6 +4902,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4315
4902
  ctx.bus.emit('activity:suppress', rel);
4316
4903
  try {
4317
4904
  const before = await Bun.file(r.abs).text();
4905
+ announceOp(r.abs, rel, before, {
4906
+ kind: 'delete',
4907
+ id,
4908
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4909
+ });
4318
4910
  const res = await deleteElement(r.abs, id, idIndex);
4319
4911
  const after = await Bun.file(r.abs).text();
4320
4912
  if (after === before) {
@@ -4780,6 +5372,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4780
5372
  ctx.bus.emit('activity:suppress', rel);
4781
5373
  try {
4782
5374
  const before = await Bun.file(r.abs).text();
5375
+ announceOp(r.abs, rel, before, {
5376
+ kind: 'artboard',
5377
+ fn: 'resize',
5378
+ artboardId,
5379
+ args: [width, height],
5380
+ });
4783
5381
  await resizeArtboard(r.abs, artboardId, width, height);
4784
5382
  const after = await Bun.file(r.abs).text();
4785
5383
  if (after === before) {
@@ -4827,6 +5425,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4827
5425
  ctx.bus.emit('activity:suppress', rel);
4828
5426
  try {
4829
5427
  const before = await Bun.file(r.abs).text();
5428
+ announceOp(r.abs, rel, before, {
5429
+ kind: 'artboard',
5430
+ fn: 'hug',
5431
+ artboardId,
5432
+ args: [fixed, freezeHeight],
5433
+ });
4830
5434
  await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
4831
5435
  const after = await Bun.file(r.abs).text();
4832
5436
  if (after === before) {
@@ -4876,6 +5480,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4876
5480
  ctx.bus.emit('activity:suppress', rel);
4877
5481
  try {
4878
5482
  const before = await Bun.file(r.abs).text();
5483
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
4879
5484
  await setArtboardKind(r.abs, artboardId, kind);
4880
5485
  const after = await Bun.file(r.abs).text();
4881
5486
  if (after === before) {
@@ -4898,6 +5503,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4898
5503
  }
4899
5504
  }
4900
5505
 
5506
+ /** Rename an artboard (plan T25/L08 — double-click its name). */
5507
+ async function setArtboardLabelOp(input: {
5508
+ canvas?: unknown;
5509
+ artboardId?: unknown;
5510
+ label?: unknown;
5511
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
5512
+ const r = resolveCanvasAbs(input.canvas);
5513
+ if (!r.ok) return r;
5514
+ if (!takeStructuralToken()) return RATE_LIMITED;
5515
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
5516
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
5517
+ return { ok: false, status: 400, error: 'invalid artboard id' };
5518
+ }
5519
+ if (typeof input.label !== 'string' || !input.label.trim()) {
5520
+ return { ok: false, status: 400, error: 'name is required' };
5521
+ }
5522
+ const label = input.label;
5523
+ const rel = path.relative(paths.designRoot, r.abs);
5524
+ ctx.bus.emit('activity:suppress', rel);
5525
+ try {
5526
+ const before = await Bun.file(r.abs).text();
5527
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'label', artboardId, args: [label] });
5528
+ await setArtboardLabel(r.abs, artboardId, label);
5529
+ const after = await Bun.file(r.abs).text();
5530
+ if (after === before) {
5531
+ ctx.bus.emit('activity:unsuppress', rel);
5532
+ return { ok: true };
5533
+ }
5534
+ try {
5535
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-label');
5536
+ } catch {
5537
+ /* snapshot best-effort */
5538
+ }
5539
+ return { ok: true, seq: logUndo(r.abs, before, after) };
5540
+ } catch (err) {
5541
+ ctx.bus.emit('activity:unsuppress', rel);
5542
+ return {
5543
+ ok: false,
5544
+ status: err instanceof CanvasEditError ? 422 : 500,
5545
+ error: err instanceof Error ? err.message : 'set-artboard-label failed',
5546
+ };
5547
+ }
5548
+ }
5549
+
4901
5550
  const MAX_GUIDES_JSON_BYTES = 4096;
4902
5551
 
4903
5552
  /** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
@@ -4931,6 +5580,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4931
5580
  ctx.bus.emit('activity:suppress', rel);
4932
5581
  try {
4933
5582
  const before = await Bun.file(r.abs).text();
5583
+ announceOp(r.abs, rel, before, {
5584
+ kind: 'artboard',
5585
+ fn: 'guides',
5586
+ artboardId,
5587
+ args: [guides],
5588
+ });
4934
5589
  await setArtboardGuides(r.abs, artboardId, guides);
4935
5590
  const after = await Bun.file(r.abs).text();
4936
5591
  if (after === before) {
@@ -5107,6 +5762,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5107
5762
  ctx.bus.emit('activity:suppress', rel);
5108
5763
  try {
5109
5764
  const before = await Bun.file(r.abs).text();
5765
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
5110
5766
  await setArtboardStyle(r.abs, artboardId, patch);
5111
5767
  const after = await Bun.file(r.abs).text();
5112
5768
  if (after === before) {
@@ -5151,6 +5807,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5151
5807
  ctx.bus.emit('activity:unsuppress', rel);
5152
5808
  return { ok: false, status: 413, error: 'canvas source too large to grow' };
5153
5809
  }
5810
+ announceOp(r.abs, rel, before, {
5811
+ kind: 'duplicate',
5812
+ id,
5813
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
5814
+ });
5154
5815
  const res = await duplicateElement(r.abs, id, idIndex);
5155
5816
  const after = await Bun.file(r.abs).text();
5156
5817
  if (after === before) {
@@ -5476,6 +6137,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5476
6137
  const current = await Bun.file(r.abs).text();
5477
6138
  const expect = dir === 'undo' ? entry.after : entry.before;
5478
6139
  const write = dir === 'undo' ? entry.before : entry.after;
6140
+ // T26 — a project that saves through accepted revisions undoes the
6141
+ // ACTION this edit became: effect-aware, so a teammate's later change
6142
+ // elsewhere in the canvas neither blocks the undo nor gets reverted
6143
+ // with it (a whole-file swap could do only one of those).
6144
+ const runtime = ctx.syncControl?.current?.();
6145
+ if (runtime?.acceptedMode?.()) {
6146
+ const rel = path.relative(paths.repoRoot, r.abs);
6147
+ // Undo targets the edit's action; redo targets the undo that
6148
+ // reverted it (reverting a revert re-applies, effect-aware again).
6149
+ const actionId =
6150
+ dir === 'undo'
6151
+ ? (runtime.acceptedActionForContent?.(rel, entry.after) ?? null)
6152
+ : (entry.undoActionId ?? null);
6153
+ if (actionId) {
6154
+ const res = await runtime.acceptedUndo?.(actionId, dir === 'redo');
6155
+ if (res?.status === 'accepted' || res?.queued) {
6156
+ if (dir === 'undo') entry.undoActionId = res.actionId;
6157
+ else delete entry.undoActionId;
6158
+ return { ok: true, dir };
6159
+ }
6160
+ return {
6161
+ ok: false,
6162
+ status: 409,
6163
+ error:
6164
+ res?.code === 'base-conflict'
6165
+ ? 'someone changed the same thing since — undo skipped'
6166
+ : `undo refused (${res?.code ?? 'unknown'})`,
6167
+ };
6168
+ }
6169
+ }
5479
6170
  if (current !== expect) {
5480
6171
  return {
5481
6172
  ok: false,
@@ -5910,6 +6601,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5910
6601
  patchCanvasMeta,
5911
6602
  loadAnnotations,
5912
6603
  saveAnnotations,
6604
+ projectAnnotations,
5913
6605
  saveAsset,
5914
6606
  listAssets,
5915
6607
  readAssetBytes,
@@ -5921,6 +6613,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5921
6613
  saveChatAttachment,
5922
6614
  resolveChatAttachment,
5923
6615
  createCanvas,
6616
+ duplicateCanvas,
5924
6617
  deleteCanvas,
5925
6618
  moveCanvas,
5926
6619
  createFolder,
@@ -5945,6 +6638,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5945
6638
  setArtboardHugOp,
5946
6639
  setArtboardStyleOp,
5947
6640
  setArtboardKindOp,
6641
+ setArtboardLabelOp,
5948
6642
  setArtboardGuidesOp,
5949
6643
  setArtboardPrintOp,
5950
6644
  deleteArtboardOp,