@1agh/maude 0.39.1 → 0.41.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 (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -3,17 +3,37 @@
3
3
 
4
4
  import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
- import { mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
6
+ import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
8
 
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
10
10
  import {
11
+ type AssembleClip,
12
+ assembleCompSource,
11
13
  CanvasEditError,
14
+ type ClipInfo,
15
+ deleteArtboard,
16
+ deleteElement,
17
+ duplicateElement,
18
+ type EditScope,
19
+ editArrayElementString,
12
20
  editAttribute,
21
+ enumerateClips,
22
+ type InsertKind,
23
+ insertArtboard,
24
+ insertClip,
25
+ insertElement,
13
26
  type MovePosition,
14
27
  moveElement,
15
28
  removeAttribute,
29
+ removeClip,
30
+ reorderClip,
31
+ resizeArtboard,
32
+ resolveEditScope,
33
+ retimeSequence,
34
+ retimeSequenceByClip,
16
35
  editText as runEditText,
36
+ toggleClipHidden,
17
37
  } from './canvas-edit.ts';
18
38
  import type { Context } from './context.ts';
19
39
  import { createHistory } from './history.ts';
@@ -180,7 +200,7 @@ export type DeleteCanvasResult =
180
200
 
181
201
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
182
202
  export type EditOpResult =
183
- | { ok: true; delta: number }
203
+ | { ok: true; delta: number; seq?: number }
184
204
  | { ok: false; status: number; error: string };
185
205
 
186
206
  /**
@@ -230,6 +250,10 @@ export interface Api {
230
250
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
231
251
  // Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
232
252
  loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
253
+ /** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
254
+ loadCanvasSource(
255
+ file: unknown
256
+ ): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }>;
233
257
  patchCanvasMeta(
234
258
  file: string,
235
259
  patch: Record<string, unknown>
@@ -239,17 +263,27 @@ export interface Api {
239
263
  saveAnnotations(file: string, svg: string): Promise<boolean>;
240
264
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
241
265
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
266
+ /** Stage F1 — list content-addressed image/video assets for the AssetPicker. */
267
+ listAssets(): Promise<{ ok: true; assets: AssetListing[] }>;
268
+ /** DDR-148 — streaming variant for the HTTP route (100 MB video without a
269
+ * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
270
+ saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
242
271
  // Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
243
272
  // returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
244
273
  saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
245
274
  // Resolve a content-addressed attachment name (`<sha8>.<ext>`) to its absolute
246
275
  // path, or null (GET /_api/acp/attachment — the read side of the pair above).
247
276
  resolveChatAttachment(name: unknown): Promise<string | null>;
248
- // Create a blank brief board from the browser (Phase 22 POST /_api/canvas)
277
+ // Create a blank brief board OR an assembled video-comp from the browser
278
+ // (Phase 22 — POST /_api/canvas; DDR-150 P4 Task 12 adds kind "video-comp").
249
279
  createCanvas(input: {
250
280
  name?: unknown;
251
281
  kind?: unknown;
252
282
  group?: unknown;
283
+ clips?: unknown;
284
+ fps?: unknown;
285
+ width?: unknown;
286
+ height?: unknown;
253
287
  }): Promise<CreateCanvasResult>;
254
288
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
255
289
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
@@ -260,6 +294,8 @@ export interface Api {
260
294
  id?: unknown;
261
295
  property?: unknown;
262
296
  value?: unknown;
297
+ reset?: unknown;
298
+ idIndex?: unknown;
263
299
  }): Promise<EditOpResult>;
264
300
  // Phase 12 (DDR-103) — inline text-content edit (POST /_api/edit-text). Main-origin only.
265
301
  editText(input: { canvas?: unknown; id?: unknown; text?: unknown }): Promise<EditOpResult>;
@@ -281,6 +317,142 @@ export interface Api {
281
317
  refId?: unknown;
282
318
  position?: unknown;
283
319
  }): Promise<ReorderOpResult>;
320
+ /** DDR-148 — Timeline drag-to-retime a sequence's durationInFrames / from. */
321
+ retimeSequenceOp(input: {
322
+ canvas?: unknown;
323
+ // DDR-150 P2 — prefer stableId (comp-scoped, multi-comp-safe) over index.
324
+ stableId?: unknown;
325
+ artboardId?: unknown;
326
+ contentHash?: unknown;
327
+ index?: unknown;
328
+ durationInFrames?: unknown;
329
+ from?: unknown;
330
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
331
+ // DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
332
+ // gate; refuses the only clip; drops an adjacent transition in a series).
333
+ removeSequenceOp(input: {
334
+ canvas?: unknown;
335
+ stableId?: unknown;
336
+ artboardId?: unknown;
337
+ contentHash?: unknown;
338
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
339
+ // DDR-150 P4 — insert a new <Sequence> (optionally with media) after a comp's
340
+ // last clip. Returns the new clip's stableId.
341
+ insertSequenceOp(input: {
342
+ canvas?: unknown;
343
+ artboardId?: unknown;
344
+ from?: unknown;
345
+ durationInFrames?: unknown;
346
+ mediaTag?: unknown;
347
+ src?: unknown;
348
+ }): Promise<
349
+ | { ok: true; stableId: string | null; seq?: number }
350
+ | { ok: false; status: number; error: string }
351
+ >;
352
+ // DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/after a
353
+ // sibling (render stacking), reusing moveElement + the semantic gate. Both
354
+ // clips are fingerprint-checked. Returns the moved clip's (re-settled) stableId.
355
+ reorderSequenceOp(input: {
356
+ canvas?: unknown;
357
+ artboardId?: unknown;
358
+ stableId?: unknown;
359
+ contentHash?: unknown;
360
+ refStableId?: unknown;
361
+ refContentHash?: unknown;
362
+ position?: unknown;
363
+ }): Promise<
364
+ | { ok: true; stableId: string | null; seq?: number }
365
+ | { ok: false; status: number; error: string }
366
+ >;
367
+ // DDR-150 dogfood — replace a media src that lives in an array literal
368
+ // (the showreel `CLIPS[i].src` pattern), addressed by mediaArrayRef.
369
+ editArraySrcOp(input: {
370
+ canvas?: unknown;
371
+ arrayName?: unknown;
372
+ index?: unknown;
373
+ field?: unknown;
374
+ value?: unknown;
375
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
376
+ // DDR-150 dogfood — hide/show a clip (gates its body behind {false && …}).
377
+ toggleHideOp(input: {
378
+ canvas?: unknown;
379
+ stableId?: unknown;
380
+ artboardId?: unknown;
381
+ contentHash?: unknown;
382
+ }): Promise<
383
+ { ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
384
+ >;
385
+ // DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
386
+ // Read-only; the Timeline addresses every op by the returned `stableId`
387
+ // (never a regex document-order index — the multi-comp mis-hit defect).
388
+ compClips(input: { canvas?: unknown; artboardId?: unknown }): Promise<
389
+ | {
390
+ ok: true;
391
+ compName: string | null;
392
+ artboardId: string | null;
393
+ fps: number | null;
394
+ durationInFrames: number | null;
395
+ clips: Array<Omit<ClipInfo, 'start' | 'end'>>;
396
+ }
397
+ | { ok: false; status: number; error: string }
398
+ >;
399
+ // Stage I (feature-element-editing-robustness) — general element structural
400
+ // edits. Each logs a whole-file undo seq (reverted via reorderRevert).
401
+ /** Delete an element by data-cd-id (reused-component instance via idIndex). */
402
+ deleteElementOp(input: {
403
+ canvas?: unknown;
404
+ id?: unknown;
405
+ idIndex?: unknown;
406
+ }): Promise<
407
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
408
+ >;
409
+ /** Insert a synthesized div/text/image relative to a reference element. */
410
+ insertElementOp(input: {
411
+ canvas?: unknown;
412
+ refId?: unknown;
413
+ position?: unknown;
414
+ kind?: unknown;
415
+ src?: unknown;
416
+ refIndex?: unknown;
417
+ }): Promise<
418
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
419
+ >;
420
+ /** Insert a new empty artboard from a screen-size preset. */
421
+ insertArtboardOp(input: {
422
+ canvas?: unknown;
423
+ id?: unknown;
424
+ label?: unknown;
425
+ width?: unknown;
426
+ height?: unknown;
427
+ }): Promise<
428
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
429
+ >;
430
+ /** Free-hand artboard resize — write width/height numeric props (DDR-027, D4). */
431
+ resizeArtboardOp(input: {
432
+ canvas?: unknown;
433
+ artboardId?: unknown;
434
+ width?: unknown;
435
+ height?: unknown;
436
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
437
+ /** Duplicate an element (Cmd+D) — a copy as the next sibling, whole-file undo. */
438
+ duplicateElementOp(input: {
439
+ canvas?: unknown;
440
+ id?: unknown;
441
+ idIndex?: unknown;
442
+ }): Promise<
443
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
444
+ >;
445
+ /** Delete an artboard by its `id` prop (whole-file undo seq). */
446
+ deleteArtboardOp(input: {
447
+ canvas?: unknown;
448
+ artboardId?: unknown;
449
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
450
+ /** Edit-scope verdict (local vs shared component instance) for the INV-3 badge. */
451
+ editScopeOp(input: {
452
+ canvas?: unknown;
453
+ id?: unknown;
454
+ rendered?: unknown;
455
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }>;
284
456
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
285
457
  // file content swap from the in-memory revert log — immune to the positional
286
458
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -327,7 +499,10 @@ export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
327
499
  */
328
500
  export const ASSET_SESSION_BUDGET = (() => {
329
501
  const env = Number(process.env.MAUDE_ASSET_SESSION_BUDGET);
330
- return Number.isFinite(env) && env > 0 ? env : 256 * 1024 * 1024;
502
+ // DDR-148 raised 256 MB 1 GB now that the route accepts video/audio (one
503
+ // 100 MB clip would blow a 256 MB budget after a couple of drops). Still an
504
+ // aggregate per-server-instance disk-fill bound; env-overridable.
505
+ return Number.isFinite(env) && env > 0 ? env : 1024 * 1024 * 1024;
331
506
  })();
332
507
 
333
508
  /**
@@ -392,6 +567,121 @@ export interface SaveAssetResult {
392
567
  path?: string;
393
568
  }
394
569
 
570
+ /** Stage F1 — one media asset in the AssetPicker listing. */
571
+ export interface AssetListing {
572
+ /** designRoot-relative path (`assets/<sha8>.<ext>`) — what a src/href uses. */
573
+ path: string;
574
+ name: string;
575
+ ext: string;
576
+ kind: 'image' | 'video';
577
+ size: number;
578
+ mtimeMs: number;
579
+ }
580
+
581
+ /** DDR-148 — media category, decides which per-file cap applies. */
582
+ export type AssetCategory = 'image' | 'video' | 'audio';
583
+
584
+ export interface AssetTypeInfo {
585
+ /** Stored file extension (bytes decide it, never the upload name). */
586
+ ext: string;
587
+ category: AssetCategory;
588
+ }
589
+
590
+ /**
591
+ * DDR-148 — per-file ceiling for time-based media (video + audio). Images keep
592
+ * the tighter {@link ASSET_MAX_BYTES} 10 MB cap. Overridable via
593
+ * `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the
594
+ * (untrusted) canvas origin, so this cap + the session budget + the streamed
595
+ * write are the trust mitigation, exactly like the image caps (DDR-088).
596
+ */
597
+ export const ASSET_MAX_VIDEO_BYTES = (() => {
598
+ const env = Number(process.env.MAUDE_ASSET_MAX_VIDEO_BYTES);
599
+ return Number.isFinite(env) && env > 0 ? env : 100 * 1024 * 1024;
600
+ })();
601
+
602
+ /** The byte cap for a category. */
603
+ export function assetCapForCategory(category: AssetCategory): number {
604
+ return category === 'image' ? ASSET_MAX_BYTES : ASSET_MAX_VIDEO_BYTES;
605
+ }
606
+
607
+ const UNSUPPORTED_ASSET_MSG =
608
+ 'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)';
609
+
610
+ function capError(category?: AssetCategory): string {
611
+ if (category === 'image') return 'image exceeds the 10 MB cap';
612
+ const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
613
+ return `media exceeds the ${mb} MB cap`;
614
+ }
615
+
616
+ /** Concatenate a small list of chunks (used only for the ≤ few-KB sniff head). */
617
+ function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
618
+ let len = 0;
619
+ for (const c of chunks) len += c.length;
620
+ const out = new Uint8Array(len);
621
+ let off = 0;
622
+ for (const c of chunks) {
623
+ out.set(c, off);
624
+ off += c.length;
625
+ }
626
+ return out;
627
+ }
628
+
629
+ /**
630
+ * DDR-148 — magic-byte type sniff for the WIDENED asset route: images (via
631
+ * {@link sniffImageType}) PLUS time-based media. The declared name / extension
632
+ * / Content-Type is NEVER trusted — the bytes decide the stored extension AND
633
+ * the category (→ which cap applies). The server only sniffs; it never PARSES a
634
+ * container (parsing happens in the sandboxed capture browser). Anything that
635
+ * isn't a recognised raster/video/audio magic number (SVG, HTML, arbitrary
636
+ * script) → null → rejected (415). Needs ≥ 12 bytes for the ISO-BMFF brands.
637
+ */
638
+ export function sniffAssetType(bytes: Uint8Array): AssetTypeInfo | null {
639
+ const img = sniffImageType(bytes);
640
+ if (img) return { ext: img, category: 'image' };
641
+ const b = bytes;
642
+ // ISO-BMFF (mp4 / mov / m4a): "ftyp" box at offset 4, brand at offset 8.
643
+ if (
644
+ b.length >= 12 &&
645
+ b[4] === 0x66 && // f
646
+ b[5] === 0x74 && // t
647
+ b[6] === 0x79 && // y
648
+ b[7] === 0x70 // p
649
+ ) {
650
+ const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
651
+ if (brand === 'qt ') return { ext: 'mov', category: 'video' };
652
+ if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
653
+ if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
654
+ // isom / mp41 / mp42 / avc1 / iso2 / iso5 / dash / mp4v / … → treat as mp4.
655
+ return { ext: 'mp4', category: 'video' };
656
+ }
657
+ // Matroska / WebM — EBML header 1A 45 DF A3.
658
+ if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
659
+ return { ext: 'webm', category: 'video' };
660
+ }
661
+ // MP3 — "ID3" tag OR a frame-sync (0xFF followed by 0b111xxxxx).
662
+ if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) {
663
+ return { ext: 'mp3', category: 'audio' };
664
+ }
665
+ if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0) {
666
+ return { ext: 'mp3', category: 'audio' };
667
+ }
668
+ // WAV — "RIFF"????"WAVE".
669
+ if (
670
+ b.length >= 12 &&
671
+ b[0] === 0x52 &&
672
+ b[1] === 0x49 &&
673
+ b[2] === 0x46 &&
674
+ b[3] === 0x46 &&
675
+ b[8] === 0x57 &&
676
+ b[9] === 0x41 &&
677
+ b[10] === 0x56 &&
678
+ b[11] === 0x45
679
+ ) {
680
+ return { ext: 'wav', category: 'audio' };
681
+ }
682
+ return null;
683
+ }
684
+
395
685
  export function createApi(ctx: Context, hooks: ApiHooks): Api {
396
686
  const onCommentsChanged = hooks.onCommentsChanged;
397
687
  const onAnnotationsChanged = hooks.onAnnotationsChanged;
@@ -405,6 +695,67 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
405
695
  const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
406
696
  let reorderSeq = 0;
407
697
 
698
+ // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
699
+ // (retime / remove / insert / z-reorder / replace-src): every successful op
700
+ // registers its {before, after} and returns the seq; the shell keeps a
701
+ // per-canvas undo/redo stack of seqs and replays them through
702
+ // /_api/reorder-revert (guarded whole-file swap, 409 on divergence).
703
+ function logUndo(abs: string, before: string, after: string): number {
704
+ const seq = ++reorderSeq;
705
+ reorderLog.set(seq, { abs, before, after });
706
+ while (reorderLog.size > REORDER_LOG_CAP) {
707
+ const oldest = reorderLog.keys().next().value;
708
+ if (oldest === undefined) break;
709
+ reorderLog.delete(oldest);
710
+ }
711
+ return seq;
712
+ }
713
+
714
+ // ── Structural-write throttle + source-size ceiling (G3 security, DDR-152) ──
715
+ // The new structural verbs (delete / insert-element / insert-artboard /
716
+ // resize-artboard) are the first that let an UNTRUSTED active canvas both
717
+ // *remove* and *grow* its own source: the shell relays a canvas's `dgn:*`
718
+ // request after gating only on `e.source === activeWin` — there is no user
719
+ // gesture on the wire, so a hostile on-load script can drive these in a loop.
720
+ // Two bounds close the disk-fill / silent-shred DoS the adversarial review
721
+ // flagged (mirrors the ASSET_SESSION_BUDGET the /_api/asset lane already has,
722
+ // DDR-088 — this is the OTHER untrusted-origin disk-write surface):
723
+ // (a) a per-api token bucket caps the sustained rate. A human does a few
724
+ // structural edits/sec; a scripted loop can't beat the refill, which
725
+ // keeps every whole-file _history snapshot + RAM undo entry rate-bound.
726
+ // (b) a growth op is refused once the source already exceeds
727
+ // MAX_CANVAS_SOURCE, so inserts can't inflate the .tsx (and the snapshot
728
+ // + undo copies it holds) without bound. Deletes/shrinks always pass.
729
+ // Bucket state is per-createApi (each test/instance starts full); env-tunable.
730
+ const STRUCTURAL_BURST = (() => {
731
+ const env = Number(process.env.MAUDE_STRUCTURAL_BURST);
732
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 40;
733
+ })();
734
+ const STRUCTURAL_REFILL_PER_SEC = 8;
735
+ let structuralTokens = STRUCTURAL_BURST;
736
+ let structuralLastRefill = Date.now();
737
+ /** Consume one token; false when the caller is over the sustained rate. */
738
+ function takeStructuralToken(): boolean {
739
+ const now = Date.now();
740
+ structuralTokens = Math.min(
741
+ STRUCTURAL_BURST,
742
+ structuralTokens + ((now - structuralLastRefill) / 1000) * STRUCTURAL_REFILL_PER_SEC
743
+ );
744
+ structuralLastRefill = now;
745
+ if (structuralTokens < 1) return false;
746
+ structuralTokens -= 1;
747
+ return true;
748
+ }
749
+ const RATE_LIMITED = {
750
+ ok: false as const,
751
+ status: 429,
752
+ error: 'too many structural edits — slow down',
753
+ };
754
+ const MAX_CANVAS_SOURCE = (() => {
755
+ const env = Number(process.env.MAUDE_MAX_CANVAS_SOURCE);
756
+ return Number.isFinite(env) && env > 0 ? Math.floor(env) : 512 * 1024;
757
+ })();
758
+
408
759
  function fileSlug(file: string): string {
409
760
  return canvasSlugFromRel(file, paths.designRel);
410
761
  }
@@ -987,51 +1338,191 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
987
1338
  // user input, but a poisoned designRoot must still not escape).
988
1339
  // Running total of bytes this server instance has actually written (post-dedupe).
989
1340
  let assetBytesWritten = 0;
990
- async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
991
- if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
992
- if (bytes.length > ASSET_MAX_BYTES) {
993
- return { ok: false, status: 413, error: 'asset exceeds the 10 MB cap' };
994
- }
995
- const kind = sniffImageType(bytes);
996
- if (!kind) {
997
- return {
998
- ok: false,
999
- status: 415,
1000
- error: 'unsupported image type png/jpeg/gif/webp only (SVG rejected)',
1001
- };
1002
- }
1003
- const sha8 = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8);
1004
- const name = `${sha8}.${kind}`;
1341
+
1342
+ /**
1343
+ * DDR-148 the streaming write path behind `POST /_api/asset`. Reads the
1344
+ * request body chunk-by-chunk so a 100 MB video never lands as a single
1345
+ * ArrayBuffer in RAM (the memory-amplification vector DDR-088 §review flagged
1346
+ * for the untrusted canvas origin — worse now that the per-file cap is 100 MB,
1347
+ * not 10 MB). Sniffs the type from the head, applies the CATEGORY cap while
1348
+ * streaming (image 10 MB · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
1349
+ * to a temp file, then content-addresses (sha8) → dedupe-rename. Nothing is
1350
+ * ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
1351
+ * both the route and any programmatic caller share ONE tested path.
1352
+ */
1353
+ async function saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult> {
1005
1354
  const assetsDir = path.join(paths.designRoot, 'assets');
1006
- const fileAbs = path.join(assetsDir, name);
1007
- // Containment backstop — the name is content-addressed (sha8 hex + sniffed
1008
- // ext), so there is no user-controlled path segment, but assert anyway.
1009
- const resolved = path.resolve(fileAbs);
1010
- const assetsResolved = path.resolve(assetsDir);
1011
- if (resolved !== path.join(assetsResolved, name)) {
1012
- return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
1013
- }
1355
+ const tmpName = `.tmp-${crypto.randomBytes(8).toString('hex')}`;
1356
+ const tmpAbs = path.join(assetsDir, tmpName);
1357
+ const hash = crypto.createHash('sha256');
1358
+ let sink: Bun.FileSink | null = null;
1359
+ let typeInfo: AssetTypeInfo | null = null;
1360
+ let cap = ASSET_MAX_VIDEO_BYTES; // provisional max until the head is sniffed
1361
+ let total = 0;
1362
+ const headChunks: Uint8Array[] = [];
1363
+ let headLen = 0;
1364
+
1365
+ const cleanup = async () => {
1366
+ try {
1367
+ if (sink) await sink.end();
1368
+ } catch {
1369
+ /* sink already closed */
1370
+ }
1371
+ try {
1372
+ await rm(tmpAbs, { force: true });
1373
+ } catch {
1374
+ /* temp already gone */
1375
+ }
1376
+ };
1377
+ const flushHead = () => {
1378
+ for (const c of headChunks) {
1379
+ hash.update(c);
1380
+ sink?.write(c);
1381
+ }
1382
+ headChunks.length = 0;
1383
+ headLen = 0;
1384
+ };
1385
+
1386
+ // Explicit reader (NOT `for await` — Bun's HTTP `req.body` ReadableStream
1387
+ // does not implement Symbol.asyncIterator, so async-iteration throws
1388
+ // "undefined is not a function"; getReader().read() works for every stream).
1389
+ const reader = stream.getReader();
1014
1390
  try {
1015
- // Dedupe identical bytes hash to the same name; skip the write if present.
1016
- if (!(await Bun.file(fileAbs).exists())) {
1017
- // Aggregate write budget (DDR-088 follow-up) bounds a scripted
1018
- // one-byte-mutation disk-fill loop from the untrusted canvas origin.
1019
- // Only a genuinely NEW file counts (a dedupe hit is free).
1020
- if (assetBytesWritten + bytes.length > ASSET_SESSION_BUDGET) {
1021
- return {
1022
- ok: false,
1023
- status: 429,
1024
- error: 'asset write budget exceeded for this server session',
1025
- };
1391
+ await mkdir(assetsDir, { recursive: true });
1392
+ for (;;) {
1393
+ const { done, value } = await reader.read();
1394
+ if (done) break;
1395
+ const chunk = value;
1396
+ if (!chunk || chunk.length === 0) continue;
1397
+ total += chunk.length;
1398
+ if (total > cap) {
1399
+ await cleanup();
1400
+ return { ok: false, status: 413, error: capError(typeInfo?.category) };
1026
1401
  }
1027
- await mkdir(assetsDir, { recursive: true });
1028
- await Bun.write(fileAbs, bytes);
1029
- assetBytesWritten += bytes.length;
1402
+ if (!typeInfo) {
1403
+ headChunks.push(chunk);
1404
+ headLen += chunk.length;
1405
+ if (headLen >= 12) {
1406
+ typeInfo = sniffAssetType(concatBytes(headChunks));
1407
+ if (!typeInfo) {
1408
+ await cleanup(); // nothing written yet — no temp file to remove
1409
+ return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
1410
+ }
1411
+ cap = assetCapForCategory(typeInfo.category);
1412
+ if (total > cap) {
1413
+ await cleanup();
1414
+ return { ok: false, status: 413, error: capError(typeInfo.category) };
1415
+ }
1416
+ sink = Bun.file(tmpAbs).writer();
1417
+ flushHead();
1418
+ }
1419
+ } else {
1420
+ hash.update(chunk);
1421
+ sink?.write(chunk);
1422
+ }
1423
+ }
1424
+
1425
+ if (total === 0) {
1426
+ await cleanup();
1427
+ return { ok: false, status: 400, error: 'empty body' };
1428
+ }
1429
+ // A body shorter than 12 bytes never triggered the mid-stream sniff.
1430
+ if (!typeInfo) {
1431
+ typeInfo = sniffAssetType(concatBytes(headChunks));
1432
+ if (!typeInfo) {
1433
+ await cleanup();
1434
+ return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
1435
+ }
1436
+ cap = assetCapForCategory(typeInfo.category);
1437
+ if (total > cap) {
1438
+ await cleanup();
1439
+ return { ok: false, status: 413, error: capError(typeInfo.category) };
1440
+ }
1441
+ sink = Bun.file(tmpAbs).writer();
1442
+ flushHead();
1443
+ }
1444
+ if (sink) await sink.end();
1445
+
1446
+ const sha8 = hash.digest('hex').slice(0, 8);
1447
+ const name = `${sha8}.${typeInfo.ext}`;
1448
+ const fileAbs = path.join(assetsDir, name);
1449
+ // Containment backstop — the name is content-addressed (sha8 hex + sniffed
1450
+ // ext), no user-controlled segment, but assert anyway (poisoned designRoot).
1451
+ if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), name)) {
1452
+ await rm(tmpAbs, { force: true });
1453
+ return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
1454
+ }
1455
+ // Dedupe — identical bytes hash to the same name; drop the temp copy.
1456
+ if (await Bun.file(fileAbs).exists()) {
1457
+ await rm(tmpAbs, { force: true });
1458
+ return { ok: true, path: `assets/${name}` };
1459
+ }
1460
+ // Aggregate write budget — bounds a scripted disk-fill loop from the
1461
+ // untrusted canvas origin. Only a genuinely NEW file counts.
1462
+ if (assetBytesWritten + total > ASSET_SESSION_BUDGET) {
1463
+ await rm(tmpAbs, { force: true });
1464
+ return {
1465
+ ok: false,
1466
+ status: 429,
1467
+ error: 'asset write budget exceeded for this server session',
1468
+ };
1030
1469
  }
1470
+ await rename(tmpAbs, fileAbs);
1471
+ assetBytesWritten += total;
1472
+ return { ok: true, path: `assets/${name}` };
1031
1473
  } catch (err) {
1474
+ await cleanup();
1032
1475
  return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
1476
+ } finally {
1477
+ try {
1478
+ reader.releaseLock();
1479
+ } catch {
1480
+ /* already released */
1481
+ }
1482
+ }
1483
+ }
1484
+
1485
+ async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
1486
+ if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
1487
+ return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
1488
+ }
1489
+
1490
+ // Stage F1 (feature-element-editing-robustness) — list the versioned content-
1491
+ // addressed media under <designRoot>/assets/ for the AssetPicker (Replace
1492
+ // image / insert image). Read-only; returns image + video assets only (never
1493
+ // arbitrary files), newest first, capped. MAIN-ORIGIN ONLY at the route layer.
1494
+ async function listAssets(): Promise<{ ok: true; assets: AssetListing[] }> {
1495
+ const assetsDir = path.join(paths.designRoot, 'assets');
1496
+ const IMG = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg']);
1497
+ const VID = new Set(['.mp4', '.webm', '.mov', '.m4v', '.ogg']);
1498
+ let entries: string[] = [];
1499
+ try {
1500
+ entries = await readdir(assetsDir);
1501
+ } catch {
1502
+ return { ok: true, assets: [] }; // no assets dir yet
1503
+ }
1504
+ const out: AssetListing[] = [];
1505
+ for (const name of entries) {
1506
+ const ext = path.extname(name).toLowerCase();
1507
+ const kind = IMG.has(ext) ? 'image' : VID.has(ext) ? 'video' : null;
1508
+ if (!kind) continue;
1509
+ let size = 0;
1510
+ let mtimeMs = 0;
1511
+ try {
1512
+ // lstat (not stat): a symlink planted under assets/ resolves to isFile()
1513
+ // === false here → skipped. Defends the listing against a symlink that
1514
+ // would otherwise be served by the /assets/<file> static route.
1515
+ const st = await lstat(path.join(assetsDir, name));
1516
+ if (!st.isFile()) continue;
1517
+ size = st.size;
1518
+ mtimeMs = st.mtimeMs;
1519
+ } catch {
1520
+ continue;
1521
+ }
1522
+ out.push({ path: `assets/${name}`, name, ext: ext.slice(1), kind, size, mtimeMs });
1033
1523
  }
1034
- return { ok: true, path: `assets/${name}` };
1524
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
1525
+ return { ok: true, assets: out.slice(0, 500) };
1035
1526
  }
1036
1527
 
1037
1528
  // Phase 31 follow-up — persist an image pasted straight from the clipboard into
@@ -1112,11 +1603,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1112
1603
  name?: unknown;
1113
1604
  kind?: unknown;
1114
1605
  group?: unknown;
1606
+ // DDR-150 P4 Task 12 — video-comp assemble payload (kind === 'video-comp').
1607
+ clips?: unknown;
1608
+ fps?: unknown;
1609
+ width?: unknown;
1610
+ height?: unknown;
1115
1611
  }): Promise<CreateCanvasResult> {
1116
- // v1 only stamps blank boards generation stays with `/design:new` (Claude).
1612
+ // v1 stamps blank brief-boards + assembled video-comps richer generation
1613
+ // stays with `/design:new` (Claude).
1117
1614
  const kind = input.kind == null || input.kind === '' ? 'brief-board' : input.kind;
1118
- if (kind !== 'brief-board') {
1119
- return { ok: false, status: 400, error: 'only kind "brief-board" is supported' };
1615
+ if (kind !== 'brief-board' && kind !== 'video-comp') {
1616
+ return { ok: false, status: 400, error: 'kind must be "brief-board" or "video-comp"' };
1617
+ }
1618
+ // Parse + validate the assemble clip list up front (video-comp only).
1619
+ const assembleClips: AssembleClip[] = [];
1620
+ if (kind === 'video-comp') {
1621
+ if (!Array.isArray(input.clips) || input.clips.length === 0) {
1622
+ return { ok: false, status: 400, error: 'video-comp needs a non-empty clips[]' };
1623
+ }
1624
+ if (input.clips.length > 60) {
1625
+ return { ok: false, status: 400, error: 'too many clips (max 60)' };
1626
+ }
1627
+ for (const raw of input.clips) {
1628
+ const c = raw as { src?: unknown; mediaKind?: unknown; durationInFrames?: unknown };
1629
+ const src = typeof c?.src === 'string' ? c.src : '';
1630
+ if (!src) return { ok: false, status: 400, error: 'each clip needs a src' };
1631
+ const mediaKind = c?.mediaKind === 'audio' ? 'audio' : 'video';
1632
+ const durationInFrames = Number.isFinite(Number(c?.durationInFrames))
1633
+ ? Math.max(1, Math.min(100000, Math.round(Number(c.durationInFrames))))
1634
+ : null;
1635
+ assembleClips.push({ src, mediaKind, durationInFrames });
1636
+ }
1120
1637
  }
1121
1638
  const v = validateCanvasName(input.name);
1122
1639
  if (!v.ok || !v.name || !v.componentName) {
@@ -1169,25 +1686,55 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1169
1686
  const slug = fileSlug(rel);
1170
1687
  const dsName = cfg.defaultDesignSystem || 'project';
1171
1688
  const platform = 'desktop';
1172
- const tsx = renderBriefBoard({
1173
- name: v.name,
1174
- componentName: v.componentName,
1175
- dsName,
1176
- platform,
1177
- seedHint: 'Empty brief board annotate me',
1178
- historyDir: path.posix.join(paths.designRel, '_history', slug),
1179
- });
1689
+ let tsx: string;
1690
+ if (kind === 'video-comp') {
1691
+ try {
1692
+ tsx = assembleCompSource(v.componentName, assembleClips, {
1693
+ fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
1694
+ width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
1695
+ height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
1696
+ });
1697
+ } catch (err) {
1698
+ return {
1699
+ ok: false,
1700
+ status: 400,
1701
+ error: err instanceof Error ? err.message : 'assemble failed',
1702
+ };
1703
+ }
1704
+ } else {
1705
+ tsx = renderBriefBoard({
1706
+ name: v.name,
1707
+ componentName: v.componentName,
1708
+ dsName,
1709
+ platform,
1710
+ seedHint: 'Empty brief board — annotate me',
1711
+ historyDir: path.posix.join(paths.designRel, '_history', slug),
1712
+ });
1713
+ }
1180
1714
  const now = new Date().toISOString();
1181
- const meta = {
1182
- kind: 'brief-board',
1183
- title: v.name,
1184
- subtitle: 'brief board',
1185
- brief: v.name,
1186
- designSystem: dsName,
1187
- platform,
1188
- created: now,
1189
- last_modified: now,
1190
- };
1715
+ const meta =
1716
+ kind === 'video-comp'
1717
+ ? {
1718
+ kind: 'video-comp',
1719
+ title: v.name,
1720
+ subtitle: 'assembled reel',
1721
+ brief: v.name,
1722
+ tags: ['video'],
1723
+ designSystem: dsName,
1724
+ platform,
1725
+ created: now,
1726
+ last_modified: now,
1727
+ }
1728
+ : {
1729
+ kind: 'brief-board',
1730
+ title: v.name,
1731
+ subtitle: 'brief board',
1732
+ brief: v.name,
1733
+ designSystem: dsName,
1734
+ platform,
1735
+ created: now,
1736
+ last_modified: now,
1737
+ };
1191
1738
  await Bun.write(fileAbs, tsx);
1192
1739
  await Bun.write(
1193
1740
  path.join(groupAbs, `${v.name}.meta.json`),
@@ -1356,6 +1903,27 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1356
1903
  return { ok: true, abs };
1357
1904
  }
1358
1905
 
1906
+ /**
1907
+ * DDR-148 — read a canvas's RAW .tsx source (containment via resolveCanvasAbs).
1908
+ * The Timeline panel parses `<Sequence>`/`<TransitionSeries.Sequence>` blocks
1909
+ * + `interpolate` windows from it to draw the sequence/keyframe rows. Read-only,
1910
+ * MAIN-ORIGIN ONLY at the route layer (never the untrusted canvas iframe).
1911
+ */
1912
+ async function loadCanvasSource(
1913
+ file: unknown
1914
+ ): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }> {
1915
+ const r = resolveCanvasAbs(file);
1916
+ if (!r.ok) return r;
1917
+ const f = Bun.file(r.abs);
1918
+ if (!(await f.exists())) return { ok: false, status: 404, error: 'canvas not found' };
1919
+ const source = await f.text();
1920
+ // A comp is small; never stream a huge blob into the panel.
1921
+ if (source.length > 512 * 1024) {
1922
+ return { ok: false, status: 413, error: 'canvas source too large for the timeline parser' };
1923
+ }
1924
+ return { ok: true, source };
1925
+ }
1926
+
1359
1927
  // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
1360
1928
  const CD_ID_RE = /^[0-9a-f]{8}$/;
1361
1929
 
@@ -1374,11 +1942,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1374
1942
  const rel = path.relative(paths.designRoot, abs);
1375
1943
  ctx.bus.emit('activity:suppress', rel);
1376
1944
  try {
1945
+ // DDR-150 dogfood #1 — capture before/after so the edit registers in the
1946
+ // whole-file undo log (the Timeline replace-src path rides this; other
1947
+ // inspector edits get an undoable seq for free).
1948
+ const before = await Bun.file(abs).text();
1377
1949
  const res = await run();
1378
1950
  // `changed === false` = the op collapsed to a no-op and nothing hit disk —
1379
1951
  // NOT `delta === 0`, which an equal-length replacement also produces.
1380
- if (res.changed === false) ctx.bus.emit('activity:unsuppress', rel);
1381
- return { ok: true, delta: res.delta };
1952
+ if (res.changed === false) {
1953
+ ctx.bus.emit('activity:unsuppress', rel);
1954
+ return { ok: true, delta: res.delta };
1955
+ }
1956
+ const after = await Bun.file(abs).text();
1957
+ const seq = after !== before ? logUndo(abs, before, after) : undefined;
1958
+ return { ok: true, delta: res.delta, seq };
1382
1959
  } catch (err) {
1383
1960
  ctx.bus.emit('activity:unsuppress', rel);
1384
1961
  return {
@@ -1395,11 +1972,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1395
1972
  property?: unknown;
1396
1973
  value?: unknown;
1397
1974
  reset?: unknown;
1975
+ idIndex?: unknown;
1398
1976
  }): Promise<EditOpResult> {
1399
1977
  const r = resolveCanvasAbs(input.canvas);
1400
1978
  if (!r.ok) return r;
1401
1979
  const id = typeof input.id === 'string' ? input.id.trim() : '';
1402
1980
  if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
1981
+ // Stage H3 — optional DOM-occurrence index. Present only for a whole-instance
1982
+ // move/resize (reposition/resize-request); routes the write to the dragged
1983
+ // component instance's own `<Component/>` usage so it stays LOCAL. Absent for
1984
+ // knob / paste-style edits (those stay global on the shared inner element).
1985
+ const idIndex =
1986
+ typeof input.idIndex === 'number' && Number.isInteger(input.idIndex) && input.idIndex >= 0
1987
+ ? input.idIndex
1988
+ : undefined;
1403
1989
  const property = typeof input.property === 'string' ? input.property.trim() : '';
1404
1990
  // CSS property names are ASCII letters + hyphens only (optionally a leading
1405
1991
  // `-` for vendor prefixes) — reject anything that could smuggle a second key
@@ -1414,7 +2000,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1414
2000
  if (input.reset === true) {
1415
2001
  return suppressedEdit(
1416
2002
  r.abs,
1417
- () => removeAttribute(r.abs, id, `style.${camel}`),
2003
+ () => removeAttribute(r.abs, id, `style.${camel}`, idIndex),
1418
2004
  'reset failed'
1419
2005
  );
1420
2006
  }
@@ -1427,7 +2013,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1427
2013
  // `#fff`, `8px`, `700`, `1.5` all ride verbatim.
1428
2014
  return suppressedEdit(
1429
2015
  r.abs,
1430
- () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value)),
2016
+ () => editAttribute(r.abs, id, `style.${camel}`, JSON.stringify(value), idIndex),
1431
2017
  'edit failed'
1432
2018
  );
1433
2019
  }
@@ -1478,6 +2064,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1478
2064
  const value = typeof input.value === 'string' ? input.value : '';
1479
2065
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
1480
2066
  if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
2067
+ // DDR-150 P3 — a media/image `src` edit (the clip-replace path) must stay a
2068
+ // contained asset reference: no path traversal, no script/file/data schemes.
2069
+ if (
2070
+ attr === 'src' &&
2071
+ (/\.\./.test(value) || /^\s*(javascript|vbscript|file|data):/i.test(value))
2072
+ ) {
2073
+ return {
2074
+ ok: false,
2075
+ status: 400,
2076
+ error: 'src must be a contained asset path (no ../ or javascript:/file:/data: schemes)',
2077
+ };
2078
+ }
1481
2079
  // Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
1482
2080
  // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
1483
2081
  // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
@@ -1566,6 +2164,640 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1566
2164
  }
1567
2165
  }
1568
2166
 
2167
+ /**
2168
+ * DDR-148 — Timeline drag-to-retime. Rewrites the `durationInFrames` / `from`
2169
+ * of the `index`-th sequence (document order). MAIN-ORIGIN ONLY at the route
2170
+ * layer. Snapshots pre-retime for /design:rollback; suppresses the agent-rim.
2171
+ */
2172
+ async function retimeSequenceOp(input: {
2173
+ canvas?: unknown;
2174
+ stableId?: unknown;
2175
+ artboardId?: unknown;
2176
+ contentHash?: unknown;
2177
+ index?: unknown;
2178
+ durationInFrames?: unknown;
2179
+ from?: unknown;
2180
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2181
+ const r = resolveCanvasAbs(input.canvas);
2182
+ if (!r.ok) return r;
2183
+ // DDR-150 P2 — prefer the comp-scoped stableId (multi-comp-safe) over the
2184
+ // legacy whole-file index. The index path stays for back-compat.
2185
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2186
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2187
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2188
+ const index = Number.isInteger(input.index) ? (input.index as number) : -1;
2189
+ if (!stableId && (index < 0 || index > 500))
2190
+ return { ok: false, status: 400, error: 'invalid sequence index' };
2191
+ const frames = (v: unknown, lo: number): number | undefined => {
2192
+ const n = Math.round(Number(v));
2193
+ return Number.isFinite(n) ? Math.max(lo, Math.min(100000, n)) : undefined;
2194
+ };
2195
+ const patch: { durationInFrames?: number; from?: number } = {};
2196
+ if (input.durationInFrames != null) patch.durationInFrames = frames(input.durationInFrames, 1);
2197
+ if (input.from != null) patch.from = frames(input.from, 0);
2198
+ if (patch.durationInFrames == null && patch.from == null) {
2199
+ return { ok: false, status: 400, error: 'nothing to retime' };
2200
+ }
2201
+ const rel = path.relative(paths.designRoot, r.abs);
2202
+ ctx.bus.emit('activity:suppress', rel);
2203
+ try {
2204
+ const before = await Bun.file(r.abs).text();
2205
+ if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
2206
+ else await retimeSequence(r.abs, index, patch);
2207
+ const after = await Bun.file(r.abs).text();
2208
+ if (after === before) {
2209
+ ctx.bus.emit('activity:unsuppress', rel);
2210
+ return { ok: true };
2211
+ }
2212
+ try {
2213
+ await history.writeSnapshot(rel, before, 'pre-retime');
2214
+ } catch {
2215
+ /* snapshot best-effort */
2216
+ }
2217
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2218
+ } catch (err) {
2219
+ ctx.bus.emit('activity:unsuppress', rel);
2220
+ return {
2221
+ ok: false,
2222
+ status: err instanceof CanvasEditError ? 422 : 500,
2223
+ error: err instanceof Error ? err.message : 'retime failed',
2224
+ };
2225
+ }
2226
+ }
2227
+
2228
+ async function removeSequenceOp(input: {
2229
+ canvas?: unknown;
2230
+ stableId?: unknown;
2231
+ artboardId?: unknown;
2232
+ contentHash?: unknown;
2233
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2234
+ const r = resolveCanvasAbs(input.canvas);
2235
+ if (!r.ok) return r;
2236
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2237
+ if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
2238
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2239
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2240
+ const rel = path.relative(paths.designRoot, r.abs);
2241
+ ctx.bus.emit('activity:suppress', rel);
2242
+ try {
2243
+ const before = await Bun.file(r.abs).text();
2244
+ await removeClip(r.abs, artboardId, stableId, contentHash);
2245
+ const after = await Bun.file(r.abs).text();
2246
+ if (after === before) {
2247
+ ctx.bus.emit('activity:unsuppress', rel);
2248
+ return { ok: true };
2249
+ }
2250
+ try {
2251
+ await history.writeSnapshot(rel, before, 'pre-remove-clip');
2252
+ } catch {
2253
+ /* snapshot best-effort */
2254
+ }
2255
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2256
+ } catch (err) {
2257
+ ctx.bus.emit('activity:unsuppress', rel);
2258
+ return {
2259
+ ok: false,
2260
+ status: err instanceof CanvasEditError ? 422 : 500,
2261
+ error: err instanceof Error ? err.message : 'remove failed',
2262
+ };
2263
+ }
2264
+ }
2265
+
2266
+ async function insertSequenceOp(input: {
2267
+ canvas?: unknown;
2268
+ artboardId?: unknown;
2269
+ from?: unknown;
2270
+ durationInFrames?: unknown;
2271
+ mediaTag?: unknown;
2272
+ src?: unknown;
2273
+ }): Promise<
2274
+ | { ok: true; stableId: string | null; seq?: number }
2275
+ | { ok: false; status: number; error: string }
2276
+ > {
2277
+ const r = resolveCanvasAbs(input.canvas);
2278
+ if (!r.ok) return r;
2279
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2280
+ const from = Number.isFinite(Number(input.from))
2281
+ ? Math.max(0, Math.round(Number(input.from)))
2282
+ : 0;
2283
+ const durationInFrames = Number.isFinite(Number(input.durationInFrames))
2284
+ ? Math.max(1, Math.min(100000, Math.round(Number(input.durationInFrames))))
2285
+ : 90;
2286
+ const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
2287
+ const src = typeof input.src === 'string' ? input.src : null;
2288
+ const rel = path.relative(paths.designRoot, r.abs);
2289
+ ctx.bus.emit('activity:suppress', rel);
2290
+ try {
2291
+ const before = await Bun.file(r.abs).text();
2292
+ const res = await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
2293
+ const after = await Bun.file(r.abs).text();
2294
+ let seq: number | undefined;
2295
+ if (after === before) {
2296
+ ctx.bus.emit('activity:unsuppress', rel);
2297
+ } else {
2298
+ try {
2299
+ await history.writeSnapshot(rel, before, 'pre-insert-clip');
2300
+ } catch {
2301
+ /* snapshot best-effort */
2302
+ }
2303
+ seq = logUndo(r.abs, before, after);
2304
+ }
2305
+ return { ok: true, stableId: res.stableId, seq };
2306
+ } catch (err) {
2307
+ ctx.bus.emit('activity:unsuppress', rel);
2308
+ return {
2309
+ ok: false,
2310
+ status: err instanceof CanvasEditError ? 422 : 500,
2311
+ error: err instanceof Error ? err.message : 'insert failed',
2312
+ };
2313
+ }
2314
+ }
2315
+
2316
+ // Stage I (feature-element-editing-robustness) — general element structural
2317
+ // edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
2318
+ // MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
2319
+ // under a seq (logUndo) so Cmd+Z reverts through /_api/reorder-revert (a
2320
+ // structural edit renumbers positional data-cd-ids → an inverse descriptor
2321
+ // goes stale). Same agent-rim suppression + pre-op snapshot as reorder.
2322
+
2323
+ /** Delete the element with `data-cd-id === id` (reused-component instance via idIndex). */
2324
+ async function deleteElementOp(input: {
2325
+ canvas?: unknown;
2326
+ id?: unknown;
2327
+ idIndex?: unknown;
2328
+ }): Promise<
2329
+ { ok: true; deletedId: string; seq?: number } | { ok: false; status: number; error: string }
2330
+ > {
2331
+ const r = resolveCanvasAbs(input.canvas);
2332
+ if (!r.ok) return r;
2333
+ if (!takeStructuralToken()) return RATE_LIMITED;
2334
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2335
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2336
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2337
+ const rel = path.relative(paths.designRoot, r.abs);
2338
+ ctx.bus.emit('activity:suppress', rel);
2339
+ try {
2340
+ const before = await Bun.file(r.abs).text();
2341
+ const res = await deleteElement(r.abs, id, idIndex);
2342
+ const after = await Bun.file(r.abs).text();
2343
+ if (after === before) {
2344
+ ctx.bus.emit('activity:unsuppress', rel);
2345
+ return { ok: true, deletedId: res.deletedId };
2346
+ }
2347
+ try {
2348
+ await history.writeSnapshot(rel, before, 'pre-delete-element');
2349
+ } catch {
2350
+ /* snapshot best-effort */
2351
+ }
2352
+ return { ok: true, deletedId: res.deletedId, seq: logUndo(r.abs, before, after) };
2353
+ } catch (err) {
2354
+ ctx.bus.emit('activity:unsuppress', rel);
2355
+ return {
2356
+ ok: false,
2357
+ status: err instanceof CanvasEditError ? 422 : 500,
2358
+ error: err instanceof Error ? err.message : 'delete failed',
2359
+ };
2360
+ }
2361
+ }
2362
+
2363
+ /** Insert a synthesized element (div/text/image) relative to `refId`. */
2364
+ async function insertElementOp(input: {
2365
+ canvas?: unknown;
2366
+ refId?: unknown;
2367
+ position?: unknown;
2368
+ kind?: unknown;
2369
+ src?: unknown;
2370
+ refIndex?: unknown;
2371
+ }): Promise<
2372
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2373
+ > {
2374
+ const r = resolveCanvasAbs(input.canvas);
2375
+ if (!r.ok) return r;
2376
+ if (!takeStructuralToken()) return RATE_LIMITED;
2377
+ const refId = typeof input.refId === 'string' ? input.refId.trim() : '';
2378
+ if (!CD_ID_RE.test(refId)) {
2379
+ return { ok: false, status: 400, error: 'invalid reference data-cd-id' };
2380
+ }
2381
+ const position = input.position;
2382
+ if (typeof position !== 'string' || !MOVE_POSITIONS.has(position)) {
2383
+ return { ok: false, status: 400, error: 'invalid position' };
2384
+ }
2385
+ const kind = input.kind;
2386
+ if (kind !== 'div' && kind !== 'text' && kind !== 'image') {
2387
+ return { ok: false, status: 400, error: 'invalid kind (div|text|image)' };
2388
+ }
2389
+ const src = typeof input.src === 'string' ? input.src : undefined;
2390
+ const refIndex = Number.isInteger(input.refIndex) ? (input.refIndex as number) : undefined;
2391
+ const rel = path.relative(paths.designRoot, r.abs);
2392
+ ctx.bus.emit('activity:suppress', rel);
2393
+ try {
2394
+ const before = await Bun.file(r.abs).text();
2395
+ if (before.length > MAX_CANVAS_SOURCE) {
2396
+ ctx.bus.emit('activity:unsuppress', rel);
2397
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2398
+ }
2399
+ const res = await insertElement(r.abs, refId, position as MovePosition, kind as InsertKind, {
2400
+ src,
2401
+ occurrence: refIndex,
2402
+ });
2403
+ const after = await Bun.file(r.abs).text();
2404
+ if (after === before) {
2405
+ ctx.bus.emit('activity:unsuppress', rel);
2406
+ return { ok: true, newId: res.newId };
2407
+ }
2408
+ try {
2409
+ await history.writeSnapshot(rel, before, 'pre-insert-element');
2410
+ } catch {
2411
+ /* snapshot best-effort */
2412
+ }
2413
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2414
+ } catch (err) {
2415
+ ctx.bus.emit('activity:unsuppress', rel);
2416
+ return {
2417
+ ok: false,
2418
+ status: err instanceof CanvasEditError ? 422 : 500,
2419
+ error: err instanceof Error ? err.message : 'insert failed',
2420
+ };
2421
+ }
2422
+ }
2423
+
2424
+ /** Insert a new empty artboard (id/label/width/height) after the last one. */
2425
+ async function insertArtboardOp(input: {
2426
+ canvas?: unknown;
2427
+ id?: unknown;
2428
+ label?: unknown;
2429
+ width?: unknown;
2430
+ height?: unknown;
2431
+ }): Promise<
2432
+ { ok: true; artboardId: string; seq?: number } | { ok: false; status: number; error: string }
2433
+ > {
2434
+ const r = resolveCanvasAbs(input.canvas);
2435
+ if (!r.ok) return r;
2436
+ if (!takeStructuralToken()) return RATE_LIMITED;
2437
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2438
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(id)) {
2439
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2440
+ }
2441
+ const label = typeof input.label === 'string' ? input.label.slice(0, 120) : id;
2442
+ const width = Number.isFinite(Number(input.width))
2443
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.width))))
2444
+ : 0;
2445
+ const height = Number.isFinite(Number(input.height))
2446
+ ? Math.max(64, Math.min(8192, Math.round(Number(input.height))))
2447
+ : 0;
2448
+ if (!width || !height) return { ok: false, status: 400, error: 'width and height required' };
2449
+ const rel = path.relative(paths.designRoot, r.abs);
2450
+ ctx.bus.emit('activity:suppress', rel);
2451
+ try {
2452
+ const before = await Bun.file(r.abs).text();
2453
+ if (before.length > MAX_CANVAS_SOURCE) {
2454
+ ctx.bus.emit('activity:unsuppress', rel);
2455
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2456
+ }
2457
+ const res = await insertArtboard(r.abs, { id, label, width, height });
2458
+ const after = await Bun.file(r.abs).text();
2459
+ if (after === before) {
2460
+ ctx.bus.emit('activity:unsuppress', rel);
2461
+ return { ok: true, artboardId: res.artboardId };
2462
+ }
2463
+ try {
2464
+ await history.writeSnapshot(rel, before, 'pre-insert-artboard');
2465
+ } catch {
2466
+ /* snapshot best-effort */
2467
+ }
2468
+ return { ok: true, artboardId: res.artboardId, seq: logUndo(r.abs, before, after) };
2469
+ } catch (err) {
2470
+ ctx.bus.emit('activity:unsuppress', rel);
2471
+ return {
2472
+ ok: false,
2473
+ status: err instanceof CanvasEditError ? 422 : 500,
2474
+ error: err instanceof Error ? err.message : 'insert-artboard failed',
2475
+ };
2476
+ }
2477
+ }
2478
+
2479
+ /** Free-hand artboard resize (D4) — write width/height NUMERIC props (DDR-027). */
2480
+ async function resizeArtboardOp(input: {
2481
+ canvas?: unknown;
2482
+ artboardId?: unknown;
2483
+ width?: unknown;
2484
+ height?: unknown;
2485
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2486
+ const r = resolveCanvasAbs(input.canvas);
2487
+ if (!r.ok) return r;
2488
+ if (!takeStructuralToken()) return RATE_LIMITED;
2489
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2490
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2491
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2492
+ }
2493
+ const dim = (v: unknown): number | undefined =>
2494
+ Number.isFinite(Number(v)) ? Math.max(64, Math.min(8192, Math.round(Number(v)))) : undefined;
2495
+ const width = dim(input.width);
2496
+ const height = dim(input.height);
2497
+ if (width == null && height == null) {
2498
+ return { ok: false, status: 400, error: 'width or height required' };
2499
+ }
2500
+ const rel = path.relative(paths.designRoot, r.abs);
2501
+ ctx.bus.emit('activity:suppress', rel);
2502
+ try {
2503
+ const before = await Bun.file(r.abs).text();
2504
+ await resizeArtboard(r.abs, artboardId, width, height);
2505
+ const after = await Bun.file(r.abs).text();
2506
+ if (after === before) {
2507
+ ctx.bus.emit('activity:unsuppress', rel);
2508
+ return { ok: true };
2509
+ }
2510
+ try {
2511
+ await history.writeSnapshot(rel, before, 'pre-resize-artboard');
2512
+ } catch {
2513
+ /* snapshot best-effort */
2514
+ }
2515
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2516
+ } catch (err) {
2517
+ ctx.bus.emit('activity:unsuppress', rel);
2518
+ return {
2519
+ ok: false,
2520
+ status: err instanceof CanvasEditError ? 422 : 500,
2521
+ error: err instanceof Error ? err.message : 'resize-artboard failed',
2522
+ };
2523
+ }
2524
+ }
2525
+
2526
+ /** Duplicate an element (Cmd+D) — insert a copy as the next sibling. */
2527
+ async function duplicateElementOp(input: {
2528
+ canvas?: unknown;
2529
+ id?: unknown;
2530
+ idIndex?: unknown;
2531
+ }): Promise<
2532
+ { ok: true; newId: string | null; seq?: number } | { ok: false; status: number; error: string }
2533
+ > {
2534
+ const r = resolveCanvasAbs(input.canvas);
2535
+ if (!r.ok) return r;
2536
+ if (!takeStructuralToken()) return RATE_LIMITED;
2537
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2538
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2539
+ const idIndex = Number.isInteger(input.idIndex) ? (input.idIndex as number) : undefined;
2540
+ const rel = path.relative(paths.designRoot, r.abs);
2541
+ ctx.bus.emit('activity:suppress', rel);
2542
+ try {
2543
+ const before = await Bun.file(r.abs).text();
2544
+ if (before.length > MAX_CANVAS_SOURCE) {
2545
+ ctx.bus.emit('activity:unsuppress', rel);
2546
+ return { ok: false, status: 413, error: 'canvas source too large to grow' };
2547
+ }
2548
+ const res = await duplicateElement(r.abs, id, idIndex);
2549
+ const after = await Bun.file(r.abs).text();
2550
+ if (after === before) {
2551
+ ctx.bus.emit('activity:unsuppress', rel);
2552
+ return { ok: true, newId: res.newId };
2553
+ }
2554
+ try {
2555
+ await history.writeSnapshot(rel, before, 'pre-duplicate-element');
2556
+ } catch {
2557
+ /* snapshot best-effort */
2558
+ }
2559
+ return { ok: true, newId: res.newId, seq: logUndo(r.abs, before, after) };
2560
+ } catch (err) {
2561
+ ctx.bus.emit('activity:unsuppress', rel);
2562
+ return {
2563
+ ok: false,
2564
+ status: err instanceof CanvasEditError ? 422 : 500,
2565
+ error: err instanceof Error ? err.message : 'duplicate failed',
2566
+ };
2567
+ }
2568
+ }
2569
+
2570
+ /** Delete an artboard by its `id` prop (Backspace / context-menu on a frame). */
2571
+ async function deleteArtboardOp(input: {
2572
+ canvas?: unknown;
2573
+ artboardId?: unknown;
2574
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2575
+ const r = resolveCanvasAbs(input.canvas);
2576
+ if (!r.ok) return r;
2577
+ if (!takeStructuralToken()) return RATE_LIMITED;
2578
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId.trim() : '';
2579
+ if (!/^[A-Za-z][\w-]{0,63}$/.test(artboardId)) {
2580
+ return { ok: false, status: 400, error: 'invalid artboard id' };
2581
+ }
2582
+ const rel = path.relative(paths.designRoot, r.abs);
2583
+ ctx.bus.emit('activity:suppress', rel);
2584
+ try {
2585
+ const before = await Bun.file(r.abs).text();
2586
+ await deleteArtboard(r.abs, artboardId);
2587
+ const after = await Bun.file(r.abs).text();
2588
+ if (after === before) {
2589
+ ctx.bus.emit('activity:unsuppress', rel);
2590
+ return { ok: true };
2591
+ }
2592
+ try {
2593
+ await history.writeSnapshot(rel, before, 'pre-delete-artboard');
2594
+ } catch {
2595
+ /* snapshot best-effort */
2596
+ }
2597
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2598
+ } catch (err) {
2599
+ ctx.bus.emit('activity:unsuppress', rel);
2600
+ return {
2601
+ ok: false,
2602
+ status: err instanceof CanvasEditError ? 422 : 500,
2603
+ error: err instanceof Error ? err.message : 'delete-artboard failed',
2604
+ };
2605
+ }
2606
+ }
2607
+
2608
+ /**
2609
+ * Edit-scope verdict for the INV-3 predictability badge (Stage H). READ-only —
2610
+ * parses the canvas + returns whether an edit to `id` is local or shared. No
2611
+ * write, no undo, no rate-cap (a mere parse the shell runs on selection). The
2612
+ * client supplies `rendered` = the DOM occurrence count of the cd-id so the
2613
+ * `.map()` case is honest.
2614
+ */
2615
+ async function editScopeOp(input: {
2616
+ canvas?: unknown;
2617
+ id?: unknown;
2618
+ rendered?: unknown;
2619
+ }): Promise<({ ok: true } & EditScope) | { ok: false; status: number; error: string }> {
2620
+ const r = resolveCanvasAbs(input.canvas);
2621
+ if (!r.ok) return r;
2622
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
2623
+ if (!CD_ID_RE.test(id)) return { ok: false, status: 400, error: 'invalid data-cd-id' };
2624
+ const rendered = Number.isFinite(Number(input.rendered))
2625
+ ? Math.max(1, Math.round(Number(input.rendered)))
2626
+ : 1;
2627
+ try {
2628
+ const source = await Bun.file(r.abs).text();
2629
+ const scope = resolveEditScope(r.abs, source, id, rendered);
2630
+ return { ok: true, ...scope };
2631
+ } catch (err) {
2632
+ return { ok: false, status: 500, error: err instanceof Error ? err.message : 'scope failed' };
2633
+ }
2634
+ }
2635
+
2636
+ async function toggleHideOp(input: {
2637
+ canvas?: unknown;
2638
+ stableId?: unknown;
2639
+ artboardId?: unknown;
2640
+ contentHash?: unknown;
2641
+ }): Promise<
2642
+ { ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
2643
+ > {
2644
+ const r = resolveCanvasAbs(input.canvas);
2645
+ if (!r.ok) return r;
2646
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2647
+ if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
2648
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2649
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2650
+ const rel = path.relative(paths.designRoot, r.abs);
2651
+ ctx.bus.emit('activity:suppress', rel);
2652
+ try {
2653
+ const before = await Bun.file(r.abs).text();
2654
+ const res = await toggleClipHidden(r.abs, artboardId, stableId, contentHash);
2655
+ const after = await Bun.file(r.abs).text();
2656
+ if (after === before) {
2657
+ ctx.bus.emit('activity:unsuppress', rel);
2658
+ return { ok: true, hidden: res.hidden };
2659
+ }
2660
+ return { ok: true, hidden: res.hidden, seq: logUndo(r.abs, before, after) };
2661
+ } catch (err) {
2662
+ ctx.bus.emit('activity:unsuppress', rel);
2663
+ return {
2664
+ ok: false,
2665
+ status: err instanceof CanvasEditError ? 422 : 500,
2666
+ error: err instanceof Error ? err.message : 'hide failed',
2667
+ };
2668
+ }
2669
+ }
2670
+
2671
+ async function editArraySrcOp(input: {
2672
+ canvas?: unknown;
2673
+ arrayName?: unknown;
2674
+ index?: unknown;
2675
+ field?: unknown;
2676
+ value?: unknown;
2677
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2678
+ const r = resolveCanvasAbs(input.canvas);
2679
+ if (!r.ok) return r;
2680
+ const arrayName = typeof input.arrayName === 'string' ? input.arrayName : '';
2681
+ const field = typeof input.field === 'string' ? input.field : 'src';
2682
+ const index = Number.isInteger(input.index) ? (input.index as number) : -1;
2683
+ const value = typeof input.value === 'string' ? input.value : '';
2684
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(arrayName)) {
2685
+ return { ok: false, status: 400, error: 'invalid array name' };
2686
+ }
2687
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) {
2688
+ return { ok: false, status: 400, error: 'invalid field' };
2689
+ }
2690
+ if (index < 0 || index > 500) return { ok: false, status: 400, error: 'invalid index' };
2691
+ if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
2692
+ const rel = path.relative(paths.designRoot, r.abs);
2693
+ ctx.bus.emit('activity:suppress', rel);
2694
+ try {
2695
+ const before = await Bun.file(r.abs).text();
2696
+ await editArrayElementString(r.abs, arrayName, index, field, value);
2697
+ const after = await Bun.file(r.abs).text();
2698
+ if (after === before) {
2699
+ ctx.bus.emit('activity:unsuppress', rel);
2700
+ return { ok: true };
2701
+ }
2702
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2703
+ } catch (err) {
2704
+ ctx.bus.emit('activity:unsuppress', rel);
2705
+ return {
2706
+ ok: false,
2707
+ status: err instanceof CanvasEditError ? 422 : 500,
2708
+ error: err instanceof Error ? err.message : 'edit failed',
2709
+ };
2710
+ }
2711
+ }
2712
+
2713
+ async function reorderSequenceOp(input: {
2714
+ canvas?: unknown;
2715
+ artboardId?: unknown;
2716
+ stableId?: unknown;
2717
+ contentHash?: unknown;
2718
+ refStableId?: unknown;
2719
+ refContentHash?: unknown;
2720
+ position?: unknown;
2721
+ }): Promise<
2722
+ | { ok: true; stableId: string | null; seq?: number }
2723
+ | { ok: false; status: number; error: string }
2724
+ > {
2725
+ const r = resolveCanvasAbs(input.canvas);
2726
+ if (!r.ok) return r;
2727
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2728
+ const refStableId = typeof input.refStableId === 'string' ? input.refStableId : null;
2729
+ if (!stableId || !refStableId) {
2730
+ return { ok: false, status: 400, error: 'stableId and refStableId required' };
2731
+ }
2732
+ const position: MovePosition = input.position === 'before' ? 'before' : 'after';
2733
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2734
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2735
+ const refContentHash =
2736
+ typeof input.refContentHash === 'string' ? input.refContentHash : undefined;
2737
+ const rel = path.relative(paths.designRoot, r.abs);
2738
+ ctx.bus.emit('activity:suppress', rel);
2739
+ try {
2740
+ const before = await Bun.file(r.abs).text();
2741
+ const res = await reorderClip(
2742
+ r.abs,
2743
+ artboardId,
2744
+ stableId,
2745
+ contentHash,
2746
+ refStableId,
2747
+ refContentHash,
2748
+ position
2749
+ );
2750
+ const after = await Bun.file(r.abs).text();
2751
+ let seq: number | undefined;
2752
+ if (after === before) {
2753
+ ctx.bus.emit('activity:unsuppress', rel);
2754
+ } else {
2755
+ try {
2756
+ await history.writeSnapshot(rel, before, 'pre-reorder-clip');
2757
+ } catch {
2758
+ /* snapshot best-effort */
2759
+ }
2760
+ seq = logUndo(r.abs, before, after);
2761
+ }
2762
+ return { ok: true, stableId: res.stableId, seq };
2763
+ } catch (err) {
2764
+ ctx.bus.emit('activity:unsuppress', rel);
2765
+ return {
2766
+ ok: false,
2767
+ status: err instanceof CanvasEditError ? 422 : 500,
2768
+ error: err instanceof Error ? err.message : 'reorder failed',
2769
+ };
2770
+ }
2771
+ }
2772
+
2773
+ async function compClips(input: { canvas?: unknown; artboardId?: unknown }) {
2774
+ const r = resolveCanvasAbs(input.canvas);
2775
+ if (!r.ok) return r;
2776
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2777
+ try {
2778
+ const source = await Bun.file(r.abs).text();
2779
+ const result = enumerateClips(r.abs, source, artboardId);
2780
+ // Strip the internal source offsets — the client addresses by stableId.
2781
+ const clips = result.clips.map(({ start: _s, end: _e, ...c }) => c);
2782
+ return {
2783
+ ok: true as const,
2784
+ compName: result.compName,
2785
+ artboardId: result.artboardId,
2786
+ fps: result.fps,
2787
+ durationInFrames: result.durationInFrames,
2788
+ clips,
2789
+ // DDR-150 dogfood #5 — loose media beds (audio/bg video) for replace.
2790
+ media: result.media,
2791
+ };
2792
+ } catch (err) {
2793
+ return {
2794
+ ok: false as const,
2795
+ status: err instanceof CanvasEditError ? 422 : 500,
2796
+ error: err instanceof Error ? err.message : 'enumerate failed',
2797
+ };
2798
+ }
2799
+ }
2800
+
1569
2801
  async function reorderRevert(input: {
1570
2802
  canvas?: unknown;
1571
2803
  seq?: unknown;
@@ -2009,10 +3241,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2009
3241
  loadCanvasState,
2010
3242
  saveCanvasState,
2011
3243
  loadCanvasMeta,
3244
+ loadCanvasSource,
2012
3245
  patchCanvasMeta,
2013
3246
  loadAnnotations,
2014
3247
  saveAnnotations,
2015
3248
  saveAsset,
3249
+ listAssets,
3250
+ saveAssetFromStream,
2016
3251
  saveChatAttachment,
2017
3252
  resolveChatAttachment,
2018
3253
  createCanvas,
@@ -2020,7 +3255,21 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2020
3255
  editCss,
2021
3256
  editText,
2022
3257
  editAttr,
3258
+ removeSequenceOp,
3259
+ insertSequenceOp,
3260
+ reorderSequenceOp,
3261
+ editArraySrcOp,
3262
+ toggleHideOp,
3263
+ compClips,
2023
3264
  reorder,
3265
+ retimeSequenceOp,
3266
+ deleteElementOp,
3267
+ insertElementOp,
3268
+ insertArtboardOp,
3269
+ resizeArtboardOp,
3270
+ deleteArtboardOp,
3271
+ duplicateElementOp,
3272
+ editScopeOp,
2024
3273
  reorderRevert,
2025
3274
  buildIndexData,
2026
3275
  buildSystemData,