@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
@@ -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
@@ -312,8 +326,27 @@ export type CreateFolderResult =
312
326
 
313
327
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
314
328
  export type EditOpResult =
315
- | { ok: true; delta: number; seq?: number }
316
- | { ok: false; status: number; error: string };
329
+ /** `previous` — what a css/attr write replaced (`null` = was unset); absent
330
+ * when it was an expression no literal undo can restore. */
331
+ | { ok: true; delta: number; seq?: number; previous?: string | null }
332
+ /** `conflict` = the target no longer held the caller's expected value. */
333
+ | { ok: false; status: number; error: string; conflict?: true };
334
+
335
+ /**
336
+ * Optional expected-current value for a css/attr write (audit 2026-09-13 P1
337
+ * #5). An absent `expected` key keeps the unconditional legacy behaviour; a
338
+ * present one must be a bounded string or `null` ("currently unset").
339
+ */
340
+ function expectedValueOf(input: {
341
+ expected?: unknown;
342
+ }): { ok: true; expected?: string | null } | { ok: false } {
343
+ if (!Object.hasOwn(input, 'expected') || input.expected === undefined) return { ok: true };
344
+ if (input.expected === null) return { ok: true, expected: null };
345
+ if (typeof input.expected === 'string' && input.expected.length <= 256) {
346
+ return { ok: true, expected: input.expected };
347
+ }
348
+ return { ok: false };
349
+ }
317
350
 
318
351
  /**
319
352
  * Phase 12.1 (DDR-138) — result of a node-move reorder. Carries the re-settle
@@ -374,7 +407,9 @@ export interface Api {
374
407
  ): Promise<Record<string, unknown> | null>;
375
408
  // Annotations sidecar (Phase 5 — .design/<slug>.annotations.svg)
376
409
  loadAnnotations(file: string): Promise<string | null>;
377
- saveAnnotations(file: string, svg: string): Promise<boolean>;
410
+ saveAnnotations(file: string, svg: string, writeId?: string, base?: string): Promise<boolean>;
411
+ /** Materialize a document snapshot without publishing it as another user edit. */
412
+ projectAnnotations(file: string, svg: string, isCurrent: () => boolean): Promise<boolean>;
378
413
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
379
414
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
380
415
  /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
@@ -425,6 +460,10 @@ export interface Api {
425
460
  width?: unknown;
426
461
  height?: unknown;
427
462
  }): Promise<CreateCanvasResult>;
463
+ // Duplicate a canvas beside itself ("<name> copy") — POST /_api/canvas
464
+ // { duplicateOf }. Source, meta and the whiteboard layer; not comments or
465
+ // history, which belong to the original.
466
+ duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult>;
428
467
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
429
468
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
430
469
  // feature-file-tree-drag-drop-folders (Task 3) — move/rename a canvas + its
@@ -442,6 +481,8 @@ export interface Api {
442
481
  value?: unknown;
443
482
  reset?: unknown;
444
483
  idIndex?: unknown;
484
+ /** Expected current value (string) or `null` = currently unset. Absent = unconditional. */
485
+ expected?: unknown;
445
486
  }): Promise<EditOpResult>;
446
487
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
447
488
  editText(input: {
@@ -459,6 +500,8 @@ export interface Api {
459
500
  id?: unknown;
460
501
  attr?: unknown;
461
502
  value?: unknown;
503
+ reset?: unknown;
504
+ expected?: unknown;
462
505
  }): Promise<EditOpResult>;
463
506
  // Phase 12.1 (DDR-138) — node-move reorder (POST /_api/reorder). Main-origin
464
507
  // only. Moves the element with data-cd-id `id` to `position` relative to
@@ -662,6 +705,12 @@ export interface Api {
662
705
  artboardId?: unknown;
663
706
  kind?: unknown;
664
707
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
708
+ /** Rename an artboard (T25/L08 — double-click its name on the canvas). */
709
+ setArtboardLabelOp(input: {
710
+ canvas?: unknown;
711
+ artboardId?: unknown;
712
+ label?: unknown;
713
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
665
714
  /** Generic layout guides (T5) — replace-whole-prop write. */
666
715
  setArtboardGuidesOp(input: {
667
716
  canvas?: unknown;
@@ -736,9 +785,26 @@ export interface ApiHooks {
736
785
  * stays". Passing the list removes the read, and `publishComments` awaits
737
786
  * this hook BEFORE touching disk so the doc is never the stale side.
738
787
  */
739
- onCommentsChanged: (file: string, comments: Comment[]) => void | Promise<void>;
788
+ /** `base` — the list this mutation started from (a merge hint for the project). */
789
+ onCommentsChanged: (file: string, comments: Comment[], base?: Comment[]) => void | Promise<void>;
740
790
  /** Phase 8 Task 5 — fires after a successful PUT /_api/annotations write. */
741
- onAnnotationsChanged?: (file: string, svg: string) => void;
791
+ onAnnotationsChanged?: (file: string, svg: string, writeId?: string, base?: string) => void;
792
+ /**
793
+ * Accepted-revisions mode (DDR-241): propose a folder operation as ONE
794
+ * project action before touching disk. Absent, or answering `null`, means
795
+ * the project is not in that mode and the local operation is the whole story.
796
+ */
797
+ /**
798
+ * A layout (shared meta) write, with the file text it replaced — accepted
799
+ * revisions propose it with that base instead of inferring one.
800
+ */
801
+ onMetaChanged?: (file: string, text: string, baseText: string | null) => void;
802
+ proposeFolder?: (
803
+ op:
804
+ | { op: 'dir.create'; path: string }
805
+ | { op: 'dir.delete'; path: string }
806
+ | { op: 'dir.move'; from: string; to: string }
807
+ ) => Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
742
808
  /**
743
809
  * feature-file-tree-drag-drop-folders (Task 3) — is a collab room pinned
744
810
  * (a shared-doc hub provider attached, DDR-064)? `moveCanvas` refuses the
@@ -1009,7 +1075,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1009
1075
  // stores in its undo record. Ephemeral by design: a server restart drops it
1010
1076
  // (undo answers 404 and the canvas stack entry is a no-op, honest failure).
1011
1077
  const REORDER_LOG_CAP = 50;
1012
- const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
1078
+ const reorderLog = new Map<
1079
+ number,
1080
+ { abs: string; before: string; after: string; undoActionId?: string }
1081
+ >();
1013
1082
  let reorderSeq = 0;
1014
1083
 
1015
1084
  // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
@@ -1187,9 +1256,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1187
1256
  * content — otherwise the file→doc import that follows the write would see a
1188
1257
  * difference and re-enter the loop.
1189
1258
  */
1190
- async function publishComments(file: string, list: Comment[]): Promise<void> {
1259
+ async function publishComments(file: string, list: Comment[], base?: Comment[]): Promise<void> {
1191
1260
  const settled = dedupeCommentsById(list);
1192
- await onCommentsChanged(file, settled);
1261
+ await onCommentsChanged(file, settled, base ? dedupeCommentsById(base) : undefined);
1193
1262
  await saveCommentsForFile(file, settled);
1194
1263
  }
1195
1264
 
@@ -1321,6 +1390,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1321
1390
  if (!payload || typeof payload.file !== 'string' || !payload.file) return null;
1322
1391
  if (typeof payload.text !== 'string' || !payload.text.trim()) return null;
1323
1392
  const list = await loadCommentsForFile(payload.file);
1393
+ const base = structuredClone(list);
1324
1394
  const text = String(payload.text).trim().slice(0, 4000);
1325
1395
  const author =
1326
1396
  typeof payload.author === 'string' && payload.author.trim()
@@ -1369,7 +1439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1369
1439
  if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
1370
1440
  }
1371
1441
  list.push(c);
1372
- await publishComments(payload.file, list);
1442
+ await publishComments(payload.file, list, base);
1373
1443
  return c;
1374
1444
  }
1375
1445
 
@@ -1384,6 +1454,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1384
1454
  if (i < 0) continue;
1385
1455
  const entry = list[i];
1386
1456
  if (!entry) continue;
1457
+ const base = structuredClone(list);
1387
1458
  const body = payload.body.trim().slice(0, 4000);
1388
1459
  const author =
1389
1460
  typeof payload.author === 'string' && payload.author.trim()
@@ -1397,7 +1468,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1397
1468
  };
1398
1469
  entry.thread = [...entry.thread, reply];
1399
1470
  entry.mentions = mentionsUnion(entry);
1400
- await publishComments(file, list);
1471
+ await publishComments(file, list, base);
1401
1472
  return entry;
1402
1473
  }
1403
1474
  return null;
@@ -1417,6 +1488,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1417
1488
  const matches = list.filter((c) => c.id === id);
1418
1489
  const first = matches[0];
1419
1490
  if (!first) continue;
1491
+ const base = structuredClone(list);
1420
1492
  for (const entry of matches) {
1421
1493
  if (patch.status === 'resolved' || patch.status === 'open') {
1422
1494
  entry.status = patch.status;
@@ -1427,7 +1499,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1427
1499
  entry.mentions = mentionsUnion(entry);
1428
1500
  }
1429
1501
  }
1430
- await publishComments(file, list);
1502
+ await publishComments(file, list, base);
1431
1503
  return first;
1432
1504
  }
1433
1505
  return null;
@@ -1438,7 +1510,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1438
1510
  for (const [file, list] of Object.entries(all)) {
1439
1511
  const remaining = list.filter((c) => c.id !== id);
1440
1512
  if (remaining.length === list.length) continue;
1441
- await publishComments(file, remaining);
1513
+ await publishComments(file, remaining, list);
1442
1514
  return true;
1443
1515
  }
1444
1516
  return false;
@@ -1946,7 +2018,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1946
2018
  next.last_modified = new Date().toISOString();
1947
2019
  // Trailing newline — consistent with canvas-create.ts + sync/codec.ts
1948
2020
  // (mergeSharedMetaIntoLocal), so a layout edit doesn't churn the newline.
1949
- await Bun.write(metaAbs, `${JSON.stringify(next, null, 2)}\n`);
2021
+ const baseText = Object.keys(current).length ? JSON.stringify(current) : null;
2022
+ const nextText = `${JSON.stringify(next, null, 2)}\n`;
2023
+ await Bun.write(metaAbs, nextText);
2024
+ hooks.onMetaChanged?.(file, nextText, baseText);
1950
2025
  // Same reason as the annotations sidecar below, and the same bug: this
1951
2026
  // lane writes the FILE and nothing else, so in a cell — where there is no
1952
2027
  // `fs.watch` — the layout never entered the doc and never reached a peer.
@@ -1986,7 +2061,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1986
2061
  }
1987
2062
  }
1988
2063
 
1989
- async function saveAnnotations(file: string, svg: string): Promise<boolean> {
2064
+ async function saveAnnotations(
2065
+ file: string,
2066
+ svg: string,
2067
+ writeId?: string,
2068
+ base?: string
2069
+ ): Promise<boolean> {
1990
2070
  if (typeof svg !== 'string') return false;
1991
2071
  if (svg.length > 1024 * 1024) return false;
1992
2072
  // Cheap content gate — must look like an <svg> document. Avoids accidental
@@ -2004,8 +2084,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2004
2084
  // (strokesToSvg) is purely presentational — path/rect/ellipse/g/line/
2005
2085
  // polyline/text — so stripping executable constructs is zero-regression.
2006
2086
  const clean = sanitizeAnnotationSvg(svg);
2087
+ // The edit's base travels only as a merge hint for the project — bounded
2088
+ // and sanitized like the value itself, never written anywhere.
2089
+ const cleanBase =
2090
+ typeof base === 'string' &&
2091
+ base.length <= 1024 * 1024 &&
2092
+ (base === '' || /^\s*<svg[\s>]/i.test(base))
2093
+ ? base === ''
2094
+ ? ''
2095
+ : sanitizeAnnotationSvg(base)
2096
+ : undefined;
2007
2097
  await Bun.write(annotationsPath(file), clean);
2008
- onAnnotationsChanged?.(file, clean);
2098
+ onAnnotationsChanged?.(file, clean, writeId, cleanBase);
2009
2099
  // Annotations reach OTHER VIEWERS over the collab room, which is why this
2010
2100
  // never needed an `fs:any`. But the file is also a versioned, file-plane
2011
2101
  // sidecar (DDR-115), and the file plane learns about a cell's own writes
@@ -2016,6 +2106,32 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2016
2106
  return true;
2017
2107
  }
2018
2108
 
2109
+ async function projectAnnotations(
2110
+ file: string,
2111
+ svg: string,
2112
+ isCurrent: () => boolean
2113
+ ): Promise<boolean> {
2114
+ if (typeof svg !== 'string' || svg.length > 1024 * 1024 || !/^\s*<svg[\s>]/i.test(svg))
2115
+ return false;
2116
+ if (!isCurrent()) return false;
2117
+ const clean = sanitizeAnnotationSvg(svg);
2118
+ // Runtime scratch stays out of the file plane. The async IO must not touch
2119
+ // the serving file until we recheck the document; another edit may have
2120
+ // arrived while Bun.write was pending. Check + rename have no await gap.
2121
+ const scratch = path.join(paths.designRoot, '_state');
2122
+ await mkdir(scratch, { recursive: true });
2123
+ const temp = path.join(scratch, `annotations-${crypto.randomUUID()}.tmp`);
2124
+ try {
2125
+ await Bun.write(temp, clean);
2126
+ if (!isCurrent()) return false;
2127
+ renameSync(temp, annotationsPath(file));
2128
+ announceWritten(`${fileSlug(file)}.annotations.svg`);
2129
+ return true;
2130
+ } finally {
2131
+ await rm(temp, { force: true });
2132
+ }
2133
+ }
2134
+
2019
2135
  // Phase 23 — content-addressed asset write. Reachable from the (potentially
2020
2136
  // untrusted, DDR-054) canvas origin, so every cap is load-bearing, NOT
2021
2137
  // optional (DDR Task 9):
@@ -2754,6 +2870,91 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2754
2870
  return { ok: true, file: path.posix.join(paths.designRel, rel), rel, slug };
2755
2871
  }
2756
2872
 
2873
+ async function duplicateCanvas(input: { file?: unknown }): Promise<CreateCanvasResult> {
2874
+ const raw = input?.file;
2875
+ if (typeof raw !== 'string' || !raw.trim()) {
2876
+ return { ok: false, status: 400, error: 'file is required' };
2877
+ }
2878
+ let rel = raw.trim();
2879
+ try {
2880
+ rel = decodeURIComponent(rel);
2881
+ } catch {
2882
+ /* leave as-is */
2883
+ }
2884
+ rel = rel.replace(/^\/+/, '');
2885
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
2886
+ if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
2887
+ if (rel.includes('..') || !/\.tsx$/i.test(rel)) {
2888
+ return { ok: false, status: 400, error: 'invalid path' };
2889
+ }
2890
+ const fileAbs = path.resolve(path.join(paths.designRoot, rel));
2891
+ const dirAbs = path.dirname(fileAbs);
2892
+ const groups = cfg.canvasGroups.filter(
2893
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
2894
+ );
2895
+ const inGroup = groups.some((g) => {
2896
+ const gAbs = path.resolve(path.join(paths.designRoot, g.path));
2897
+ return dirAbs === gAbs || dirAbs.startsWith(`${gAbs}${path.sep}`);
2898
+ });
2899
+ if (!inGroup) {
2900
+ return {
2901
+ ok: false,
2902
+ status: 400,
2903
+ error: 'only canvases under a managed canvas group can be duplicated',
2904
+ };
2905
+ }
2906
+ if (!(await assertRealpathContained(dirAbs))) {
2907
+ return { ok: false, status: 400, error: 'source path escapes the design root via a symlink' };
2908
+ }
2909
+ if (!(await Bun.file(fileAbs).exists())) {
2910
+ return { ok: false, status: 404, error: 'canvas not found' };
2911
+ }
2912
+ const base = path.basename(rel).replace(/\.tsx$/i, '');
2913
+ const dir = path.posix.dirname(rel) === '.' ? '' : path.posix.dirname(rel);
2914
+ // "<name> copy", then "<name> copy 2", … — the first free, valid name.
2915
+ let name: string | null = null;
2916
+ for (let n = 1; n < 100 && !name; n++) {
2917
+ const suffix = n === 1 ? ' copy' : ` copy ${n}`;
2918
+ const v = validateCanvasName(`${base.slice(0, 60 - suffix.length)}${suffix}`);
2919
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
2920
+ const candidate = path.posix.join(dir, `${v.name}.tsx`);
2921
+ if (await Bun.file(path.join(paths.designRoot, candidate)).exists()) continue;
2922
+ if (await fileForSlug(fileSlug(candidate))) continue;
2923
+ name = v.name;
2924
+ }
2925
+ if (!name) return { ok: false, status: 409, error: 'too many copies of this canvas' };
2926
+ const toRel = path.posix.join(dir, `${name}.tsx`);
2927
+ const toAbs = path.join(paths.designRoot, toRel);
2928
+ await Bun.write(toAbs, await Bun.file(fileAbs).arrayBuffer());
2929
+ const metaAbs = fileAbs.replace(/\.tsx$/i, '.meta.json');
2930
+ if (await Bun.file(metaAbs).exists()) {
2931
+ let text = await Bun.file(metaAbs).text();
2932
+ try {
2933
+ const meta = JSON.parse(text) as Record<string, unknown>;
2934
+ const now = new Date().toISOString();
2935
+ if (typeof meta.title !== 'string' || meta.title === base) meta.title = name;
2936
+ meta.created = now;
2937
+ meta.last_modified = now;
2938
+ text = `${JSON.stringify(meta, null, 2)}\n`;
2939
+ } catch {
2940
+ /* an unreadable meta is copied verbatim, like the source */
2941
+ }
2942
+ await Bun.write(toAbs.replace(/\.tsx$/i, '.meta.json'), text);
2943
+ }
2944
+ const slug = fileSlug(toRel);
2945
+ // The whiteboard layer is slug-keyed at the design root (canvas-artifacts).
2946
+ const annotationsAbs = path.join(paths.designRoot, `${fileSlug(rel)}.annotations.svg`);
2947
+ if (await Bun.file(annotationsAbs).exists()) {
2948
+ await Bun.write(
2949
+ path.join(paths.designRoot, `${slug}.annotations.svg`),
2950
+ await Bun.file(annotationsAbs).arrayBuffer()
2951
+ );
2952
+ }
2953
+ ctx.bus.emit('canvas-list-update', { action: 'added', rel: toRel, slug });
2954
+ ctx.bus.emit('canvas-created', { slug });
2955
+ return { ok: true, file: path.posix.join(paths.designRel, toRel), rel: toRel, slug };
2956
+ }
2957
+
2757
2958
  // Phase 22 — SOFT-delete a canvas (DELETE /_api/canvas). Same trust boundary as
2758
2959
  // createCanvas: main-origin-only, never the untrusted canvas iframe origin
2759
2960
  // (DDR-054). Destructive, so it MOVES the whole sidecar set to
@@ -2777,6 +2978,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2777
2978
 
2778
2979
  // Only a real `.tsx` canvas, no traversal.
2779
2980
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2981
+ if (
2982
+ !/\.tsx$/i.test(rel) &&
2983
+ isSupportingFileRel(rel) &&
2984
+ (await isRegularFile(path.join(paths.designRoot, rel)))
2985
+ ) {
2986
+ return deleteSupportingFile(rel);
2987
+ }
2780
2988
  if (!/\.tsx$/i.test(rel)) {
2781
2989
  // feature-file-tree-drag-drop-folders (follow-up) — a non-.tsx target is
2782
2990
  // a folder delete (dogfood gap: the tree offered no way to remove a
@@ -2886,6 +3094,170 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2886
3094
  // itself (same `_trash/<stamp>__<slug>/` bundle shape, so a folder delete
2887
3095
  // is recoverable exactly like a single-canvas delete), then best-effort
2888
3096
  // removes whatever's left (empty subdirs, `.gitkeep` markers).
3097
+ // ── Plan T17/L03 — supporting files ──────────────────────────────────────
3098
+ // Notes, styles, data, images, media and fonts that live in a canvas folder
3099
+ // beside the canvases. They move, rename and delete as themselves. One that a
3100
+ // canvas, stylesheet or whiteboard still names is refused with the name of
3101
+ // the file that uses it: a moved or deleted asset must never leave a broken
3102
+ // image behind silently (L17).
3103
+ async function isRegularFile(abs: string): Promise<boolean> {
3104
+ try {
3105
+ return (await statp(abs)).isFile();
3106
+ } catch {
3107
+ return false;
3108
+ }
3109
+ }
3110
+
3111
+ async function supportingFileGuard(
3112
+ rel: string
3113
+ ): Promise<{ ok: true; abs: string } | { ok: false; status: number; error: string }> {
3114
+ if (rel.includes('..') || rel.startsWith('/') || isRuntimeStateRel(rel)) {
3115
+ return { ok: false, status: 400, error: 'invalid path' };
3116
+ }
3117
+ const abs = path.resolve(path.join(paths.designRoot, rel));
3118
+ const groups = cfg.canvasGroups.filter(
3119
+ (g) => g.label !== 'Design system' && !/^system(\/|$)/.test(g.path)
3120
+ );
3121
+ const inGroup = groups.some((g) =>
3122
+ abs.startsWith(`${path.resolve(path.join(paths.designRoot, g.path))}${path.sep}`)
3123
+ );
3124
+ if (!inGroup) {
3125
+ return {
3126
+ ok: false,
3127
+ status: 400,
3128
+ error: 'only files inside a canvas folder can be changed here',
3129
+ };
3130
+ }
3131
+ if (!(await assertRealpathContained(path.dirname(abs)))) {
3132
+ return { ok: false, status: 400, error: 'path escapes the design root via a symlink' };
3133
+ }
3134
+ if (!(await isRegularFile(abs))) return { ok: false, status: 404, error: 'file not found' };
3135
+ return { ok: true, abs };
3136
+ }
3137
+
3138
+ /** The first project file (canvas, stylesheet, meta, whiteboard) that names `rel`'s file. */
3139
+ async function firstReferenceTo(rel: string): Promise<string | null> {
3140
+ const base = path.posix.basename(rel);
3141
+ const { readdir, readFile } = await import('node:fs/promises');
3142
+ const walk = async (dirAbs: string, depth: number): Promise<string | null> => {
3143
+ if (depth > 12) return null;
3144
+ let entries: import('node:fs').Dirent[];
3145
+ try {
3146
+ entries = await readdir(dirAbs, { withFileTypes: true });
3147
+ } catch {
3148
+ return null;
3149
+ }
3150
+ for (const e of entries) {
3151
+ if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'node_modules') continue;
3152
+ const abs = path.join(dirAbs, e.name);
3153
+ if (e.isDirectory()) {
3154
+ const hit = await walk(abs, depth + 1);
3155
+ if (hit) return hit;
3156
+ } else if (/\.(tsx|jsx|css|meta\.json|annotations\.svg)$/i.test(e.name)) {
3157
+ const other = path.relative(paths.designRoot, abs).split(path.sep).join('/');
3158
+ if (other === rel) continue;
3159
+ const text = await readFile(abs, 'utf8').catch(() => '');
3160
+ if (text.includes(base)) return other;
3161
+ }
3162
+ }
3163
+ return null;
3164
+ };
3165
+ return walk(paths.designRoot, 0);
3166
+ }
3167
+
3168
+ async function moveSupportingFile(
3169
+ rel: string,
3170
+ toDirRaw: unknown,
3171
+ toNameRaw: unknown
3172
+ ): Promise<MoveCanvasResult> {
3173
+ const g = await supportingFileGuard(rel);
3174
+ if (!g.ok) return g;
3175
+ const ext = path.posix.extname(rel);
3176
+ let base = path.posix.basename(rel);
3177
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3178
+ const stem = String(toNameRaw).replace(new RegExp(`${ext.replace('.', '\\.')}$`, 'i'), '');
3179
+ const v = validateCanvasName(stem);
3180
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3181
+ base = `${v.name}${ext}`;
3182
+ }
3183
+ const drPrefix = paths.designRel.replace(/^\/+|\/+$/g, '');
3184
+ let toDir =
3185
+ typeof toDirRaw === 'string'
3186
+ ? toDirRaw.trim().replace(/^\/+|\/+$/g, '')
3187
+ : path.posix.dirname(rel);
3188
+ if (toDir === drPrefix || toDir === '.') toDir = '';
3189
+ else if (toDir.startsWith(`${drPrefix}/`)) toDir = toDir.slice(drPrefix.length + 1);
3190
+ const toRel = path.posix.join(toDir, base);
3191
+ if (toRel === rel)
3192
+ return { ok: false, status: 400, error: 'source and destination are the same' };
3193
+ const dest = await supportingFileGuard(toRel).then((d) =>
3194
+ d.ok
3195
+ ? { ok: false as const, status: 409, error: `a file named "${base}" already exists there` }
3196
+ : d
3197
+ );
3198
+ if (!dest.ok && dest.status !== 404) return dest;
3199
+ const toAbs = path.resolve(path.join(paths.designRoot, toRel));
3200
+ if (!(await isRegularFile(path.join(paths.designRoot, rel))))
3201
+ return { ok: false, status: 404, error: 'file not found' };
3202
+ const toDirAbs = path.dirname(toAbs);
3203
+ try {
3204
+ if (!(await statp(toDirAbs)).isDirectory()) throw new Error('not a folder');
3205
+ } catch {
3206
+ return { ok: false, status: 400, error: 'destination folder does not exist' };
3207
+ }
3208
+ const user = await firstReferenceTo(rel);
3209
+ if (user) {
3210
+ return {
3211
+ ok: false,
3212
+ status: 409,
3213
+ error: `${path.posix.basename(rel)} is used by ${user} — change that first`,
3214
+ };
3215
+ }
3216
+ await rename(g.abs, toAbs);
3217
+ ctx.bus.emit('canvas-list-update', {
3218
+ action: 'moved',
3219
+ rel: toRel,
3220
+ slug: fileSlug(toRel),
3221
+ fromRel: rel,
3222
+ fromSlug: fileSlug(rel),
3223
+ });
3224
+ return {
3225
+ ok: true,
3226
+ fromRel: rel,
3227
+ toRel,
3228
+ fromSlug: fileSlug(rel),
3229
+ toSlug: fileSlug(toRel),
3230
+ moved: [toRel],
3231
+ };
3232
+ }
3233
+
3234
+ async function deleteSupportingFile(rel: string): Promise<DeleteCanvasResult> {
3235
+ const g = await supportingFileGuard(rel);
3236
+ if (!g.ok) return g;
3237
+ const user = await firstReferenceTo(rel);
3238
+ if (user) {
3239
+ return {
3240
+ ok: false,
3241
+ status: 409,
3242
+ error: `${path.posix.basename(rel)} is used by ${user} — remove it there first`,
3243
+ };
3244
+ }
3245
+ const slug = fileSlug(rel);
3246
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
3247
+ const trashDir = path.join(paths.designRoot, '_trash', `${stamp}__${slug}`);
3248
+ await mkdir(trashDir, { recursive: true });
3249
+ const trashed = path.join(trashDir, path.posix.basename(rel));
3250
+ await rename(g.abs, trashed);
3251
+ ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
3252
+ return {
3253
+ ok: true,
3254
+ rel,
3255
+ slug,
3256
+ trashed: [path.relative(paths.repoRoot, trashed)],
3257
+ trashDir: path.relative(paths.repoRoot, trashDir),
3258
+ };
3259
+ }
3260
+
2889
3261
  async function deleteFolder(relDir: string): Promise<DeleteCanvasResult> {
2890
3262
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
2891
3263
  const dirAbs = path.join(paths.designRoot, relDir);
@@ -2942,6 +3314,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2942
3314
  p.startsWith(`${drPrefix}/`) ? p.slice(drPrefix.length + 1) : p
2943
3315
  );
2944
3316
 
3317
+ // ONE project action for the whole folder (accepted-revisions mode): the
3318
+ // hub deletes every canvas under it and the folder entry together, so a
3319
+ // peer never sees half a folder, and history shows one step.
3320
+ const folderAction = await hooks.proposeFolder?.({
3321
+ op: 'dir.delete',
3322
+ path: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3323
+ });
3324
+ if (folderAction?.status === 'rejected') {
3325
+ return {
3326
+ ok: false,
3327
+ status: 409,
3328
+ error: `the project did not accept deleting this folder (${folderAction.code ?? 'rejected'})`,
3329
+ };
3330
+ }
3331
+
2945
3332
  const trashed: string[] = [];
2946
3333
  let lastTrashDir = '';
2947
3334
  for (const r of canvasRels) {
@@ -3051,7 +3438,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3051
3438
  // never wrong about where the canvas lives) and every relocation is logged
3052
3439
  // to `_history/<toSlug>/_move.json` for forensic recovery. See the DDR
3053
3440
  // (Task 14) for the accepted-limitation writeup.
3054
- async function moveCanvas(input: { file?: unknown; toDir?: unknown }): Promise<MoveCanvasResult> {
3441
+ async function moveCanvas(input: {
3442
+ file?: unknown;
3443
+ toDir?: unknown;
3444
+ toName?: unknown;
3445
+ }): Promise<MoveCanvasResult> {
3055
3446
  const raw = input?.file;
3056
3447
  if (typeof raw !== 'string' || !raw.trim()) {
3057
3448
  return { ok: false, status: 400, error: 'file is required' };
@@ -3067,17 +3458,38 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3067
3458
  if (rel.startsWith(`${drPrefix}/`)) rel = rel.slice(drPrefix.length + 1);
3068
3459
  if (rel.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3069
3460
  if (!/\.tsx$/i.test(rel)) {
3461
+ // Plan T17/L03 — a supporting file (notes, styles, images, media in a
3462
+ // canvas folder) moves or renames as itself, never as a "folder".
3463
+ if (isSupportingFileRel(rel) && (await isRegularFile(path.join(paths.designRoot, rel)))) {
3464
+ return moveSupportingFile(rel, input?.toDir, input?.toName);
3465
+ }
3070
3466
  // feature-file-tree-drag-drop-folders (Task 11) — a non-.tsx source is a
3071
3467
  // folder move (dragging a folder onto a folder). moveFolder does its own
3072
3468
  // existence + containment validation, so an invalid path still reports
3073
3469
  // the right error from there.
3074
- return moveFolder(rel, input?.toDir);
3075
- }
3076
-
3077
- if (typeof input?.toDir !== 'string') {
3470
+ return moveFolder(rel, input?.toDir, input?.toName);
3471
+ }
3472
+
3473
+ // A rename is a move in place under a new name (`toName`, same allowlist as
3474
+ // a new canvas's name); `toDir` defaults to the canvas's own folder then.
3475
+ let newBase: string | null = null;
3476
+ if (input?.toName !== undefined && input.toName !== null && input.toName !== '') {
3477
+ const v = validateCanvasName(input.toName);
3478
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3479
+ newBase = `${v.name}.tsx`;
3480
+ }
3481
+ const toDirInput =
3482
+ typeof input?.toDir === 'string'
3483
+ ? input.toDir
3484
+ : newBase
3485
+ ? path.posix.dirname(rel) === '.'
3486
+ ? ''
3487
+ : path.posix.dirname(rel)
3488
+ : null;
3489
+ if (typeof toDirInput !== 'string') {
3078
3490
  return { ok: false, status: 400, error: 'toDir is required' };
3079
3491
  }
3080
- let toDir = input.toDir.trim().replace(/^\/+|\/+$/g, '');
3492
+ let toDir = toDirInput.trim().replace(/^\/+|\/+$/g, '');
3081
3493
  try {
3082
3494
  toDir = decodeURIComponent(toDir);
3083
3495
  } catch {
@@ -3145,7 +3557,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3145
3557
  return { ok: false, status: 404, error: 'canvas not found' };
3146
3558
  }
3147
3559
 
3148
- const base = path.basename(rel);
3560
+ const base = newBase ?? path.basename(rel);
3149
3561
  const toRel = path.posix.join(toDir, base);
3150
3562
  if (path.posix.normalize(toRel) === path.posix.normalize(rel)) {
3151
3563
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3313,7 +3725,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3313
3725
  // nested canvas at once (they live inside the moved directory); only the
3314
3726
  // slug-keyed sidecars (flat dirs like `_history/<slug>/`, not nested by
3315
3727
  // folder structure) need their own per-canvas relocation afterward.
3316
- async function moveFolder(relDir: string, toDirRaw: unknown): Promise<MoveCanvasResult> {
3728
+ async function moveFolder(
3729
+ relDir: string,
3730
+ toDirRaw: unknown,
3731
+ toNameRaw?: unknown
3732
+ ): Promise<MoveCanvasResult> {
3317
3733
  if (relDir.includes('..')) return { ok: false, status: 400, error: 'invalid path' };
3318
3734
  const dirAbs = path.join(paths.designRoot, relDir);
3319
3735
  const resolvedDesignRoot = path.resolve(paths.designRoot);
@@ -3389,7 +3805,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3389
3805
  return { ok: false, status: 400, error: 'cannot move a folder into itself' };
3390
3806
  }
3391
3807
 
3392
- const base = path.basename(relDir);
3808
+ // A RENAME is a move to a new name in the same (or another) parent — one
3809
+ // manifest action (dir.move), the same as any folder move (plan T17).
3810
+ let base = path.basename(relDir);
3811
+ if (toNameRaw !== undefined && toNameRaw !== null && toNameRaw !== '') {
3812
+ const v = validateFolderName(toNameRaw);
3813
+ if (!v.ok || !v.name) return { ok: false, status: 400, error: v.error ?? 'invalid name' };
3814
+ base = v.name;
3815
+ }
3393
3816
  const toRelDir = path.posix.join(toDir, base);
3394
3817
  if (path.posix.normalize(toRelDir) === path.posix.normalize(relDir)) {
3395
3818
  return { ok: false, status: 400, error: 'source and destination are the same' };
@@ -3443,6 +3866,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3443
3866
  }
3444
3867
  }
3445
3868
 
3869
+ // ONE project action for the whole folder (accepted-revisions mode) —
3870
+ // canvases, empty sub-folders and all. The per-canvas retire below then
3871
+ // only lets go locally; it proposes nothing of its own.
3872
+ const folderMove = await hooks.proposeFolder?.({
3873
+ op: 'dir.move',
3874
+ from: relDir.replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''),
3875
+ to: toRelDir,
3876
+ });
3877
+ if (folderMove?.status === 'rejected') {
3878
+ return {
3879
+ ok: false,
3880
+ status: 409,
3881
+ error: `the project did not accept this move (${folderMove.code ?? 'rejected'})`,
3882
+ };
3883
+ }
3884
+
3446
3885
  // Collab guard for every canvas found, BEFORE any disk mutation. Same
3447
3886
  // coordinated retire as moveCanvas — refuse only when a pinned room could
3448
3887
  // not be retired, and retire even unpinned synced documents so none of
@@ -3612,6 +4051,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3612
4051
  /* good — doesn't exist yet */
3613
4052
  }
3614
4053
 
4054
+ const folderCreate = await hooks.proposeFolder?.({
4055
+ op: 'dir.create',
4056
+ path: path.posix.join(parent, v.name),
4057
+ });
4058
+ if (folderCreate?.status === 'rejected') {
4059
+ return {
4060
+ ok: false,
4061
+ status: 409,
4062
+ error: `the project did not accept this folder (${folderCreate.code ?? 'rejected'})`,
4063
+ };
4064
+ }
4065
+
3615
4066
  await mkdir(dirAbs, { recursive: true });
3616
4067
  await Bun.write(path.join(dirAbs, '.gitkeep'), '');
3617
4068
 
@@ -3689,10 +4140,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3689
4140
  // suppression BEFORE the write so it catches the debounced fs:any, disarm on
3690
4141
  // a no-op (delta 0 = no write) or throw so a failed edit can't mute the next
3691
4142
  // genuine agent edit's rim.
4143
+ /** T24/T25 — say what a structural edit IS (sync/source-ops), for replay. */
4144
+ function announceOp(
4145
+ abs: string,
4146
+ rel: string,
4147
+ before: string,
4148
+ op: Parameters<typeof describeSourceOp>[2]
4149
+ ): void {
4150
+ const described = describeSourceOp(abs, before, op);
4151
+ if (described) ctx.bus.emit('source-op', { rel, op: described });
4152
+ }
4153
+
3692
4154
  async function suppressedEdit(
3693
4155
  abs: string,
3694
- run: () => Promise<{ delta: number; changed?: boolean }>,
3695
- errLabel: string
4156
+ run: () => Promise<{ delta: number; changed?: boolean; previous?: AttributeState }>,
4157
+ errLabel: string,
4158
+ describe?: Parameters<typeof describeSourceOp>[2]
3696
4159
  ): Promise<EditOpResult> {
3697
4160
  const rel = path.relative(paths.designRoot, abs);
3698
4161
  ctx.bus.emit('activity:suppress', rel);
@@ -3701,18 +4164,33 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3701
4164
  // whole-file undo log (the Timeline replace-src path rides this; other
3702
4165
  // inspector edits get an undoable seq for free).
3703
4166
  const before = await Bun.file(abs).text();
4167
+ // T24 — what this edit IS, so a lost race re-applies it instead of
4168
+ // turning "we both picked a colour" into a conflict.
4169
+ if (describe) {
4170
+ const op = describeSourceOp(abs, before, describe);
4171
+ if (op) ctx.bus.emit('source-op', { rel, op });
4172
+ }
3704
4173
  const res = await run();
4174
+ const previous =
4175
+ res.previous?.kind === 'absent'
4176
+ ? { previous: null }
4177
+ : res.previous?.kind === 'literal'
4178
+ ? { previous: res.previous.value }
4179
+ : {};
3705
4180
  // `changed === false` = the op collapsed to a no-op and nothing hit disk —
3706
4181
  // NOT `delta === 0`, which an equal-length replacement also produces.
3707
4182
  if (res.changed === false) {
3708
4183
  ctx.bus.emit('activity:unsuppress', rel);
3709
- return { ok: true, delta: res.delta };
4184
+ return { ok: true, delta: res.delta, ...previous };
3710
4185
  }
3711
4186
  const after = await Bun.file(abs).text();
3712
4187
  const seq = after !== before ? logUndo(abs, before, after) : undefined;
3713
- return { ok: true, delta: res.delta, seq };
4188
+ return { ok: true, delta: res.delta, seq, ...previous };
3714
4189
  } catch (err) {
3715
4190
  ctx.bus.emit('activity:unsuppress', rel);
4191
+ if (err instanceof CanvasEditError && err.conflict) {
4192
+ return { ok: false, status: 409, error: err.message, conflict: true };
4193
+ }
3716
4194
  return {
3717
4195
  ok: false,
3718
4196
  status: 422,
@@ -3728,9 +4206,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3728
4206
  value?: unknown;
3729
4207
  reset?: unknown;
3730
4208
  idIndex?: unknown;
4209
+ expected?: unknown;
3731
4210
  }): Promise<EditOpResult> {
3732
4211
  const r = resolveCanvasAbs(input.canvas);
3733
4212
  if (!r.ok) return r;
4213
+ const pre = expectedValueOf(input);
4214
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3734
4215
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3735
4216
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3736
4217
  // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
@@ -3755,8 +4236,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3755
4236
  if (input.reset === true) {
3756
4237
  return suppressedEdit(
3757
4238
  r.abs,
3758
- () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
3759
- 'reset failed'
4239
+ () =>
4240
+ removeAttribute(
4241
+ r.abs,
4242
+ id,
4243
+ `style.${camel}`,
4244
+ idIndex,
4245
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4246
+ ),
4247
+ 'reset failed',
4248
+ {
4249
+ kind: 'remove',
4250
+ id,
4251
+ attr: `style.${camel}`,
4252
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4253
+ }
3760
4254
  );
3761
4255
  }
3762
4256
  const value = typeof input.value === 'string' ? input.value : '';
@@ -3768,8 +4262,23 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3768
4262
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
3769
4263
  return suppressedEdit(
3770
4264
  r.abs,
3771
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
3772
- 'edit failed'
4265
+ () =>
4266
+ editAttribute(
4267
+ r.abs,
4268
+ id,
4269
+ `style.${camel}`,
4270
+ JSON.stringify(value),
4271
+ idIndex,
4272
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4273
+ ),
4274
+ 'edit failed',
4275
+ {
4276
+ kind: 'set',
4277
+ id,
4278
+ attr: `style.${camel}`,
4279
+ value: JSON.stringify(value),
4280
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4281
+ }
3773
4282
  );
3774
4283
  }
3775
4284
 
@@ -3802,7 +4311,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3802
4311
  return suppressedEdit(
3803
4312
  r.abs,
3804
4313
  () => runEditText(r.abs, id, text, { occurrence, before }),
3805
- 'edit failed'
4314
+ 'edit failed',
4315
+ { kind: 'text', id, text, ...(occurrence !== undefined ? { occurrence } : {}) }
3806
4316
  );
3807
4317
  }
3808
4318
 
@@ -3812,9 +4322,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3812
4322
  attr?: unknown;
3813
4323
  value?: unknown;
3814
4324
  reset?: unknown;
4325
+ expected?: unknown;
3815
4326
  }): Promise<EditOpResult> {
3816
4327
  const r = resolveCanvasAbs(input.canvas);
3817
4328
  if (!r.ok) return r;
4329
+ const pre = expectedValueOf(input);
4330
+ if (!pre.ok) return { ok: false, status: 400, error: 'invalid expected value' };
3818
4331
  const id = typeof input.id === 'string' ? input.id.trim() : '';
3819
4332
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
3820
4333
  const attr = typeof input.attr === 'string' ? input.attr.trim() : '';
@@ -3832,7 +4345,19 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3832
4345
  }
3833
4346
  // Phase 12.3 — `reset: true` REMOVES the custom attribute. No-op if absent.
3834
4347
  if (input.reset === true) {
3835
- return suppressedEdit(r.abs, () => removeAttribute(r.abs, id, attr), 'reset failed');
4348
+ return suppressedEdit(
4349
+ r.abs,
4350
+ () =>
4351
+ removeAttribute(
4352
+ r.abs,
4353
+ id,
4354
+ attr,
4355
+ undefined,
4356
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: null }
4357
+ ),
4358
+ 'reset failed',
4359
+ { kind: 'remove', id, attr }
4360
+ );
3836
4361
  }
3837
4362
  const value = typeof input.value === 'string' ? input.value : '';
3838
4363
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
@@ -3853,7 +4378,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3853
4378
  // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
3854
4379
  // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
3855
4380
  // the value (`data-x="\"ok\""`; knob-smoke finding, 2026-06-12).
3856
- return suppressedEdit(r.abs, () => editAttribute(r.abs, id, attr, value), 'edit failed');
4381
+ return suppressedEdit(
4382
+ r.abs,
4383
+ () =>
4384
+ editAttribute(
4385
+ r.abs,
4386
+ id,
4387
+ attr,
4388
+ value,
4389
+ undefined,
4390
+ pre.expected === undefined ? undefined : { expected: pre.expected, next: value }
4391
+ ),
4392
+ 'edit failed',
4393
+ { kind: 'set', id, attr, value }
4394
+ );
3857
4395
  }
3858
4396
 
3859
4397
  // Phase 12.1 (DDR-138) — snapshot stack so a reorder is undoable via
@@ -3900,6 +4438,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3900
4438
  ctx.bus.emit('activity:suppress', rel);
3901
4439
  try {
3902
4440
  const before = await Bun.file(r.abs).text();
4441
+ announceOp(r.abs, rel, before, {
4442
+ kind: 'move',
4443
+ id,
4444
+ refId,
4445
+ position: String(position),
4446
+ ...(idIndex !== undefined ? { idIndex } : {}),
4447
+ ...(refIndex !== undefined ? { refIndex } : {}),
4448
+ });
3903
4449
  const res = await moveElement(r.abs, id, refId, position as MovePosition, idIndex, refIndex);
3904
4450
  const after = await Bun.file(r.abs).text();
3905
4451
  if (after === before) {
@@ -4315,6 +4861,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4315
4861
  ctx.bus.emit('activity:suppress', rel);
4316
4862
  try {
4317
4863
  const before = await Bun.file(r.abs).text();
4864
+ announceOp(r.abs, rel, before, {
4865
+ kind: 'delete',
4866
+ id,
4867
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
4868
+ });
4318
4869
  const res = await deleteElement(r.abs, id, idIndex);
4319
4870
  const after = await Bun.file(r.abs).text();
4320
4871
  if (after === before) {
@@ -4780,6 +5331,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4780
5331
  ctx.bus.emit('activity:suppress', rel);
4781
5332
  try {
4782
5333
  const before = await Bun.file(r.abs).text();
5334
+ announceOp(r.abs, rel, before, {
5335
+ kind: 'artboard',
5336
+ fn: 'resize',
5337
+ artboardId,
5338
+ args: [width, height],
5339
+ });
4783
5340
  await resizeArtboard(r.abs, artboardId, width, height);
4784
5341
  const after = await Bun.file(r.abs).text();
4785
5342
  if (after === before) {
@@ -4827,6 +5384,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4827
5384
  ctx.bus.emit('activity:suppress', rel);
4828
5385
  try {
4829
5386
  const before = await Bun.file(r.abs).text();
5387
+ announceOp(r.abs, rel, before, {
5388
+ kind: 'artboard',
5389
+ fn: 'hug',
5390
+ artboardId,
5391
+ args: [fixed, freezeHeight],
5392
+ });
4830
5393
  await setArtboardHug(r.abs, artboardId, fixed, freezeHeight);
4831
5394
  const after = await Bun.file(r.abs).text();
4832
5395
  if (after === before) {
@@ -4876,6 +5439,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4876
5439
  ctx.bus.emit('activity:suppress', rel);
4877
5440
  try {
4878
5441
  const before = await Bun.file(r.abs).text();
5442
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'kind', artboardId, args: [kind] });
4879
5443
  await setArtboardKind(r.abs, artboardId, kind);
4880
5444
  const after = await Bun.file(r.abs).text();
4881
5445
  if (after === before) {
@@ -4898,6 +5462,50 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4898
5462
  }
4899
5463
  }
4900
5464
 
5465
+ /** Rename an artboard (plan T25/L08 — double-click its name). */
5466
+ async function setArtboardLabelOp(input: {
5467
+ canvas?: unknown;
5468
+ artboardId?: unknown;
5469
+ label?: unknown;
5470
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
5471
+ const r = resolveCanvasAbs(input.canvas);
5472
+ if (!r.ok) return r;
5473
+ if (!takeStructuralToken()) return RATE_LIMITED;
5474
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
5475
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
5476
+ return { ok: false, status: 400, error: 'invalid artboard id' };
5477
+ }
5478
+ if (typeof input.label !== 'string' || !input.label.trim()) {
5479
+ return { ok: false, status: 400, error: 'name is required' };
5480
+ }
5481
+ const label = input.label;
5482
+ const rel = path.relative(paths.designRoot, r.abs);
5483
+ ctx.bus.emit('activity:suppress', rel);
5484
+ try {
5485
+ const before = await Bun.file(r.abs).text();
5486
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'label', artboardId, args: [label] });
5487
+ await setArtboardLabel(r.abs, artboardId, label);
5488
+ const after = await Bun.file(r.abs).text();
5489
+ if (after === before) {
5490
+ ctx.bus.emit('activity:unsuppress', rel);
5491
+ return { ok: true };
5492
+ }
5493
+ try {
5494
+ await history.writeSnapshot(rel, before, 'pre-set-artboard-label');
5495
+ } catch {
5496
+ /* snapshot best-effort */
5497
+ }
5498
+ return { ok: true, seq: logUndo(r.abs, before, after) };
5499
+ } catch (err) {
5500
+ ctx.bus.emit('activity:unsuppress', rel);
5501
+ return {
5502
+ ok: false,
5503
+ status: err instanceof CanvasEditError ? 422 : 500,
5504
+ error: err instanceof Error ? err.message : 'set-artboard-label failed',
5505
+ };
5506
+ }
5507
+ }
5508
+
4901
5509
  const MAX_GUIDES_JSON_BYTES = 4096;
4902
5510
 
4903
5511
  /** Generic layout guides (T5) — Inspector/skill writer. Replace-whole-prop,
@@ -4931,6 +5539,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4931
5539
  ctx.bus.emit('activity:suppress', rel);
4932
5540
  try {
4933
5541
  const before = await Bun.file(r.abs).text();
5542
+ announceOp(r.abs, rel, before, {
5543
+ kind: 'artboard',
5544
+ fn: 'guides',
5545
+ artboardId,
5546
+ args: [guides],
5547
+ });
4934
5548
  await setArtboardGuides(r.abs, artboardId, guides);
4935
5549
  const after = await Bun.file(r.abs).text();
4936
5550
  if (after === before) {
@@ -5107,6 +5721,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5107
5721
  ctx.bus.emit('activity:suppress', rel);
5108
5722
  try {
5109
5723
  const before = await Bun.file(r.abs).text();
5724
+ announceOp(r.abs, rel, before, { kind: 'artboard', fn: 'style', artboardId, args: [patch] });
5110
5725
  await setArtboardStyle(r.abs, artboardId, patch);
5111
5726
  const after = await Bun.file(r.abs).text();
5112
5727
  if (after === before) {
@@ -5151,6 +5766,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5151
5766
  ctx.bus.emit('activity:unsuppress', rel);
5152
5767
  return { ok: false, status: 413, error: 'canvas source too large to grow' };
5153
5768
  }
5769
+ announceOp(r.abs, rel, before, {
5770
+ kind: 'duplicate',
5771
+ id,
5772
+ ...(idIndex !== undefined ? { occurrence: idIndex } : {}),
5773
+ });
5154
5774
  const res = await duplicateElement(r.abs, id, idIndex);
5155
5775
  const after = await Bun.file(r.abs).text();
5156
5776
  if (after === before) {
@@ -5476,6 +6096,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5476
6096
  const current = await Bun.file(r.abs).text();
5477
6097
  const expect = dir === 'undo' ? entry.after : entry.before;
5478
6098
  const write = dir === 'undo' ? entry.before : entry.after;
6099
+ // T26 — a project that saves through accepted revisions undoes the
6100
+ // ACTION this edit became: effect-aware, so a teammate's later change
6101
+ // elsewhere in the canvas neither blocks the undo nor gets reverted
6102
+ // with it (a whole-file swap could do only one of those).
6103
+ const runtime = ctx.syncControl?.current?.();
6104
+ if (runtime?.acceptedMode?.()) {
6105
+ const rel = path.relative(paths.repoRoot, r.abs);
6106
+ // Undo targets the edit's action; redo targets the undo that
6107
+ // reverted it (reverting a revert re-applies, effect-aware again).
6108
+ const actionId =
6109
+ dir === 'undo'
6110
+ ? (runtime.acceptedActionForContent?.(rel, entry.after) ?? null)
6111
+ : (entry.undoActionId ?? null);
6112
+ if (actionId) {
6113
+ const res = await runtime.acceptedUndo?.(actionId, dir === 'redo');
6114
+ if (res?.status === 'accepted' || res?.queued) {
6115
+ if (dir === 'undo') entry.undoActionId = res.actionId;
6116
+ else delete entry.undoActionId;
6117
+ return { ok: true, dir };
6118
+ }
6119
+ return {
6120
+ ok: false,
6121
+ status: 409,
6122
+ error:
6123
+ res?.code === 'base-conflict'
6124
+ ? 'someone changed the same thing since — undo skipped'
6125
+ : `undo refused (${res?.code ?? 'unknown'})`,
6126
+ };
6127
+ }
6128
+ }
5479
6129
  if (current !== expect) {
5480
6130
  return {
5481
6131
  ok: false,
@@ -5910,6 +6560,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5910
6560
  patchCanvasMeta,
5911
6561
  loadAnnotations,
5912
6562
  saveAnnotations,
6563
+ projectAnnotations,
5913
6564
  saveAsset,
5914
6565
  listAssets,
5915
6566
  readAssetBytes,
@@ -5921,6 +6572,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5921
6572
  saveChatAttachment,
5922
6573
  resolveChatAttachment,
5923
6574
  createCanvas,
6575
+ duplicateCanvas,
5924
6576
  deleteCanvas,
5925
6577
  moveCanvas,
5926
6578
  createFolder,
@@ -5945,6 +6597,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5945
6597
  setArtboardHugOp,
5946
6598
  setArtboardStyleOp,
5947
6599
  setArtboardKindOp,
6600
+ setArtboardLabelOp,
5948
6601
  setArtboardGuidesOp,
5949
6602
  setArtboardPrintOp,
5950
6603
  deleteArtboardOp,