@1agh/maude 0.39.0 → 0.40.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 (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -345,7 +345,13 @@ export function isEditableTarget(t: EventTarget | null): boolean {
345
345
  */
346
346
  export function isOverlayTarget(t: EventTarget | null): boolean {
347
347
  if (!t || !(t as Element).closest) return false;
348
- return !!(t as Element).closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin');
348
+ // [data-mediaref-player] the inline <video>/<audio controls> on a media
349
+ // reference chip (DDR-150 dogfood #8). The router must never claim (and
350
+ // preventDefault) pointerdowns over the player, or its native controls
351
+ // (play button, scrubber drag, volume) die under the move tool.
352
+ return !!(t as Element).closest(
353
+ '.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mediaref-player]'
354
+ );
349
355
  }
350
356
 
351
357
  export function useInputRouter(opts: UseInputRouterOptions): void {
@@ -79,6 +79,36 @@ export const RUNTIME_PACKAGES = [
79
79
  // every real canvas) returned HTTP 500 "Bundle failed".
80
80
  'lib0/decoding',
81
81
  'lib0/encoding',
82
+ // DDR-148 — Remotion video-comp authoring. `remotion` carries the React
83
+ // context stack (RemotionEnvironment / Sequence / Timeline) that a video-comp
84
+ // canvas AND the `<Player>` preview BOTH consume — so it MUST be a single
85
+ // externalised bundle (importmap-shared), exactly like React itself, or the
86
+ // Player's provider and the composition's `useCurrentFrame()` bind to
87
+ // different context instances and the comp renders frozen at frame 0 (the
88
+ // dual-package hazard). `@remotion/player` + `@remotion/transitions` each
89
+ // externalise `remotion` (→ the one bundle) and INLINE their own stateless
90
+ // helpers — `remotion/no-react` (interpolate/random/validators/NoReactInternals,
91
+ // all pure), plus `@remotion/paths`/`@remotion/shapes` for transitions — which
92
+ // hold no cross-package state, so a per-bundle copy is harmless. NOT shipping
93
+ // `@remotion/renderer`/`@remotion/web-renderer` (export goes through the
94
+ // capture spine). Pre-built into dist/runtime + floored in .min-sizes.json.
95
+ 'remotion',
96
+ '@remotion/player',
97
+ '@remotion/media',
98
+ '@remotion/transitions',
99
+ // Transition presentations are separate subpath modules by design (Remotion
100
+ // tree-shakes to what you import). But a canvas import specifier that isn't
101
+ // in RUNTIME_PACKAGES tries to resolve against the USER's node_modules at
102
+ // request-time Bun.build — which doesn't exist on an npm/marketplace install
103
+ // — so EVERY presentation the skill teaches must be pre-bundled + importmap-
104
+ // routed. v1 ships the six core wipes ("join 4 clips + crossfade" vocabulary);
105
+ // exotic presentations (dreamy-zoom, film-burn, …) are a documented follow-up.
106
+ '@remotion/transitions/fade',
107
+ '@remotion/transitions/slide',
108
+ '@remotion/transitions/wipe',
109
+ '@remotion/transitions/flip',
110
+ '@remotion/transitions/clock-wipe',
111
+ '@remotion/transitions/none',
82
112
  ] as const;
83
113
 
84
114
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -18,13 +18,13 @@ import { spawn } from 'node:child_process';
18
18
 
19
19
  import { createAcp } from './acp/index.ts';
20
20
  import { createActivity } from './activity.ts';
21
- import { createApi } from './api.ts';
21
+ import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
22
22
  import { bootSelfHeal } from './boot-self-heal.ts';
23
23
  import { createCanvasListWatch } from './canvas-list-watch.ts';
24
24
  import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
25
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
26
26
  import { createCollab } from './collab/index.ts';
27
- import { createContext } from './context.ts';
27
+ import { createContext, reloadConfig } from './context.ts';
28
28
  import { createFsWatch } from './fs-watch.ts';
29
29
  import { createGitWatch } from './git/watch.ts';
30
30
  import { createHttp } from './http.ts';
@@ -124,13 +124,16 @@ const { port: BASE_PORT, explicit: PORT_EXPLICIT } = resolvePort();
124
124
 
125
125
  type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
126
126
 
127
- // Phase 23 security review (DDR-088 follow-up) — hard ceiling on a buffered
128
- // request body. Without it Bun's 128 MB default applies, so `POST /_api/asset`
129
- // would buffer up to 128 MB into RAM BEFORE api.saveAsset's 10 MB check runs
130
- // (memory amplification from the untrusted canvas origin). 16 MB covers every
131
- // legit body (10 MB asset + 1 MB annotation SVG + small JSON) with headroom and
132
- // bounds the pre-handler buffer; the authoritative per-route caps still apply.
133
- const MAX_REQUEST_BODY = 16 * 1024 * 1024;
127
+ // Phase 23 security review (DDR-088) + DDR-148 — hard ceiling on a request
128
+ // body. Bun's 128 MB default would let the untrusted canvas origin send huge
129
+ // bodies to any route. DDR-088 pinned this at 16 MB. DDR-148 raises it to the
130
+ // video cap + 8 MB headroom so `POST /_api/asset` can accept a 100 MB clip
131
+ // but that route now STREAMS the body to disk (saveAssetFromStream), so a big
132
+ // upload never lands as one ArrayBuffer in RAM. Other routes keep their small
133
+ // LOGICAL caps (annotation 1 MB, JSON) — a body over those still rejects; the
134
+ // only change is Bun accepts more bytes pre-handler (a bandwidth-bounded
135
+ // transient-buffer tradeoff on the buffering routes, noted in DDR-148).
136
+ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
134
137
 
135
138
  function startServer(port: number): BunServer {
136
139
  return Bun.serve<WsData, never>({
@@ -401,6 +404,28 @@ const gitWatch = createGitWatch(ctx);
401
404
  // `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
402
405
  const canvasListWatch = createCanvasListWatch(ctx);
403
406
 
407
+ // Config hot-reload — `/design:setup-ds` (or a hand edit) rewrites
408
+ // `.design/config.json` mid-session; without a re-read the server keeps
409
+ // serving the boot snapshot and a newly added canvas group (`system`) never
410
+ // reaches /_index-data, so scaffolded DS files stay invisible even on a
411
+ // manual tree reload. On change: swap ctx.cfg in place (reloadConfig), let
412
+ // canvasListWatch's set-diff emit `canvas-list-update` for canvases the new
413
+ // groups uncover, and tell shells to refetch /_config.
414
+ // RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
415
+ const CONFIG_RELOAD_DEBOUNCE_MS = 150;
416
+ let configReloadTimer: ReturnType<typeof setTimeout> | null = null;
417
+ ctx.bus.on('fs:json', (rel: string) => {
418
+ if (rel.replace(/\\/g, '/').replace(/^\/+/, '') !== 'config.json') return;
419
+ if (configReloadTimer) clearTimeout(configReloadTimer);
420
+ configReloadTimer = setTimeout(() => {
421
+ configReloadTimer = null;
422
+ if (!reloadConfig(ctx)) return;
423
+ console.log(' config.json changed — reloaded live.');
424
+ void canvasListWatch.refresh();
425
+ ctx.bus.emit('config-updated');
426
+ }, CONFIG_RELOAD_DEBOUNCE_MS);
427
+ });
428
+
404
429
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
405
430
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
406
431
  // off after fsWatch so the agent's bus subscription receives every fs event.
@@ -4,6 +4,8 @@
4
4
  // (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
5
5
 
6
6
  import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
7
9
  import { join } from 'node:path';
8
10
 
9
11
  import { AcpBridge } from '../acp/bridge.ts';
@@ -112,6 +114,136 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
112
114
  }, 15000);
113
115
  });
114
116
 
117
+ describe('AcpBridge — cross-restart session resume (DDR-125 gap)', () => {
118
+ let dir: string;
119
+
120
+ afterEach(async () => {
121
+ if (dir) await rm(dir, { recursive: true, force: true });
122
+ });
123
+
124
+ test('a persisted sessionId resumes via loadSession instead of spawning a new session', async () => {
125
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
126
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
127
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
128
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
129
+ const storePath = join(dir, 'c1.session.json');
130
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-session-abc' }));
131
+
132
+ const updates: unknown[] = [];
133
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
134
+ try {
135
+ bridge.setSessionStorePath(storePath);
136
+ await bridge.prompt('pokracuj', 'c1');
137
+ // Resumed the persisted id verbatim — never fell through to session/new
138
+ // (which would have minted a fresh `mock-session-N`).
139
+ expect(bridge.sessionId).toBe('persisted-session-abc');
140
+ // The mock's replay notification must NOT reach the UI sink.
141
+ expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
142
+ } finally {
143
+ await bridge.stop();
144
+ }
145
+ }, 15000);
146
+
147
+ test('a resume that fails (pruned session) falls back to newSession and re-persists the new id', async () => {
148
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
149
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
150
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
151
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
152
+ const storePath = join(dir, 'c1.session.json');
153
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'not-found' }));
154
+
155
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
156
+ try {
157
+ bridge.setSessionStorePath(storePath);
158
+ await bridge.prompt('pokracuj', 'c1');
159
+ expect(bridge.sessionId).toBe('mock-session-1'); // fresh session, not the stale id
160
+ const stored = JSON.parse(await readFile(storePath, 'utf8'));
161
+ expect(stored.sessionId).toBe('mock-session-1'); // re-persisted so the NEXT restart resumes it
162
+ } finally {
163
+ await bridge.stop();
164
+ }
165
+ }, 15000);
166
+
167
+ test('no prior chat for this sidecar behaves exactly as before — a brand new session is created and persisted', async () => {
168
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
169
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
170
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
171
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
172
+ const storePath = join(dir, 'brand-new.session.json');
173
+
174
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
175
+ try {
176
+ bridge.setSessionStorePath(storePath);
177
+ await bridge.prompt('hi', 'c1');
178
+ expect(bridge.sessionId).toBe('mock-session-1');
179
+ const stored = JSON.parse(await readFile(storePath, 'utf8'));
180
+ expect(stored.sessionId).toBe('mock-session-1');
181
+ } finally {
182
+ await bridge.stop();
183
+ }
184
+ }, 15000);
185
+
186
+ test('no sidecar wired at all (e.g. a warm-up before any prompt) never touches disk and behaves exactly as before', async () => {
187
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
188
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
189
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
190
+
191
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
192
+ try {
193
+ // setSessionStorePath is never called — sessionStorePath stays null.
194
+ await bridge.prompt('hi', 'c1');
195
+ expect(bridge.sessionId).toBe('mock-session-1');
196
+ } finally {
197
+ await bridge.stop();
198
+ }
199
+ }, 15000);
200
+
201
+ test('a malformed persisted sessionId (not a plausible shape) is rejected — falls back to newSession', async () => {
202
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
203
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
204
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
205
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
206
+ const storePath = join(dir, 'c1.session.json');
207
+ // Not a plausible sessionId shape (embedded newline + non-UUID charset) —
208
+ // must never reach the wire as a `loadSession` sessionId.
209
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'evil\nsessionId; rm -rf /' }));
210
+
211
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
212
+ try {
213
+ bridge.setSessionStorePath(storePath);
214
+ await bridge.prompt('hi', 'c1');
215
+ expect(bridge.sessionId).toBe('mock-session-1'); // rejected — fresh session instead
216
+ } finally {
217
+ await bridge.stop();
218
+ }
219
+ }, 15000);
220
+
221
+ test('warm and prompt racing for the same chat share one resume attempt (no replaying race)', async () => {
222
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
223
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
224
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
225
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
226
+ const storePath = join(dir, 'c1.session.json');
227
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-race-session' }));
228
+
229
+ const updates: unknown[] = [];
230
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
231
+ try {
232
+ bridge.setSessionStorePath(storePath);
233
+ // Fire warmUp and prompt concurrently for the same chatId — both call
234
+ // sessionFor('c1') before either has resolved.
235
+ const [, result] = await Promise.all([bridge.warmUp('c1'), bridge.prompt('pokracuj', 'c1')]);
236
+ expect(bridge.sessionId).toBe('persisted-race-session');
237
+ expect(result.stopReason).toBe('end_turn');
238
+ // Only ONE resume attempt happened — the mock's replay text never leaked,
239
+ // and the prompt's own turn still streamed normally.
240
+ expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
241
+ } finally {
242
+ await bridge.stop();
243
+ }
244
+ }, 15000);
245
+ });
246
+
115
247
  describe('probeAcpAvailability — not-connected detection', () => {
116
248
  test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
117
249
  process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
@@ -6,6 +6,8 @@
6
6
  // the cached list to a freshly-opened socket.
7
7
 
8
8
  import { afterEach, describe, expect, test } from 'bun:test';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
9
11
  import { join } from 'node:path';
10
12
 
11
13
  import type { ServerWebSocket } from 'bun';
@@ -73,9 +75,11 @@ describe('AcpBridge.warmUp — publishes the command catalogue without a prompt'
73
75
  describe('Acp manager — warm frame broadcasts + open replays commands', () => {
74
76
  test('a {t:warm} frame yields a {t:commands} frame, replayed to a new socket', async () => {
75
77
  useMockAgent();
76
- const ctx = {
77
- paths: { repoRoot: process.cwd(), designRoot: join(process.cwd(), '.design') },
78
- } as unknown as Context;
78
+ // Isolated tmp designRoot — `warm` now also writes a session-store sidecar
79
+ // (bridge.ts sessionFor's resume path), so this must not land in a real
80
+ // repo path.
81
+ const designRoot = await mkdtemp(join(tmpdir(), 'acp-commands-test-'));
82
+ const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
79
83
  const acp = createAcp(ctx);
80
84
 
81
85
  const a = fakeWs('ws-a');
@@ -103,6 +107,7 @@ describe('Acp manager — warm frame broadcasts + open replays commands', () =>
103
107
  acp.onClose(a.ws);
104
108
  // give teardown a tick to kill the subprocess
105
109
  await new Promise((r) => setTimeout(r, 50));
110
+ await rm(designRoot, { recursive: true, force: true });
106
111
  }
107
112
  }, 20000);
108
113
  });
@@ -16,6 +16,7 @@ import {
16
16
  type ArrowStroke,
17
17
  type ImageStroke,
18
18
  type LinkStroke,
19
+ type MediaRefStroke,
19
20
  type RectStroke,
20
21
  type StickyStroke,
21
22
  type Stroke,
@@ -314,6 +315,52 @@ describe('annotations round-trip / link chip (Phase 23)', () => {
314
315
  });
315
316
  });
316
317
 
318
+ describe('annotations round-trip / media-reference chip (DDR-150 P4)', () => {
319
+ const ref: MediaRefStroke = {
320
+ id: 'mr1',
321
+ tool: 'mediaref',
322
+ x: 120,
323
+ y: 90,
324
+ w: 240,
325
+ h: 72,
326
+ src: 'assets/9f8e7d6c.mp4',
327
+ mediaKind: 'video',
328
+ title: 'b-roll-sunset.mp4',
329
+ };
330
+
331
+ test('mediaref survives serialize → parse with all fields intact', () => {
332
+ const [parsed] = svgToStrokes(strokesToSvg([ref])) as MediaRefStroke[];
333
+ expect(parsed).toEqual(ref);
334
+ });
335
+
336
+ test('mediaref persists data-src (the agent enumerates refs off it), never a <Video>', () => {
337
+ const svg = strokesToSvg([ref]);
338
+ expect(svg).toContain('data-src="assets/9f8e7d6c.mp4"');
339
+ expect(svg).toContain('data-media-kind="video"');
340
+ expect(svg).not.toContain('<Video');
341
+ expect(svg).not.toContain('<video');
342
+ });
343
+
344
+ test('mediaref serialize → parse is idempotent', () => {
345
+ const once = strokesToSvg([{ ...ref, mediaKind: 'audio', src: 'assets/track.mp3' }]);
346
+ expect(reparse(once)).toBe(once);
347
+ });
348
+
349
+ test('mediaref SVG survives sanitizeAnnotationSvg byte-intact (PUT-path)', () => {
350
+ const svg = strokesToSvg([ref]);
351
+ expect(sanitizeAnnotationSvg(svg)).toBe(svg);
352
+ });
353
+
354
+ test('a data-src with a traversal/scheme is dropped to an inert empty ref', () => {
355
+ const svg =
356
+ '<svg xmlns="http://www.w3.org/2000/svg" data-mdcc-annotations="1">' +
357
+ '<g data-id="mr2" data-tool="mediaref" data-src="../../etc/passwd" data-media-kind="video" data-title="x">' +
358
+ '<rect x="0" y="0" width="240" height="72" rx="8" ry="8"/></g></svg>';
359
+ const [parsed] = svgToStrokes(svg) as MediaRefStroke[];
360
+ expect(parsed?.src).toBe('');
361
+ });
362
+ });
363
+
317
364
  describe('annotations round-trip / standalone vs anchored text', () => {
318
365
  test('standalone text round-trips its world (x, y) and stays unanchored', () => {
319
366
  const t: TextStroke = {
@@ -122,6 +122,82 @@ describe('/_api/canvas — POST round-trip', () => {
122
122
  }
123
123
  });
124
124
 
125
+ test('assembles a video-comp (kind: video-comp) from dropped clips (DDR-150 P4 Task 12)', async () => {
126
+ const { root, designRoot } = makeSandbox();
127
+ const port = nextPort();
128
+ const proc = await bootServer(root, port);
129
+ try {
130
+ const r = await fetch(`http://localhost:${port}/_api/canvas`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({
134
+ name: 'My Reel',
135
+ kind: 'video-comp',
136
+ fps: 30,
137
+ clips: [
138
+ { src: 'assets/a.mp4', mediaKind: 'video', durationInFrames: 60 },
139
+ { src: 'assets/b.mp4', mediaKind: 'video' },
140
+ { src: 'assets/music.mp3', mediaKind: 'audio' },
141
+ ],
142
+ }),
143
+ });
144
+ expect(r.status).toBe(201);
145
+ const j = (await r.json()) as { ok: boolean; rel: string };
146
+ expect(j.ok).toBe(true);
147
+ const tsx = readFileSync(join(designRoot, 'ui', 'My Reel.tsx'), 'utf8');
148
+ expect(tsx).toContain('<VideoComp component={Comp}');
149
+ expect(tsx).toContain('<OffthreadVideo src="assets/a.mp4" />');
150
+ expect(tsx).toContain('<Audio src="assets/music.mp3" />');
151
+ expect(tsx).toContain('name="clip-1"');
152
+ const m = JSON.parse(readFileSync(join(designRoot, 'ui', 'My Reel.meta.json'), 'utf8'));
153
+ expect(m.kind).toBe('video-comp');
154
+ } finally {
155
+ await killProc(proc);
156
+ }
157
+ });
158
+
159
+ test('rejects a video-comp with an empty clips[] (400) and a traversal src (400)', async () => {
160
+ const { root } = makeSandbox();
161
+ const port = nextPort();
162
+ const proc = await bootServer(root, port);
163
+ try {
164
+ const empty = await fetch(`http://localhost:${port}/_api/canvas`, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ name: 'Empty', kind: 'video-comp', clips: [] }),
168
+ });
169
+ expect(empty.status).toBe(400);
170
+ const bad = await fetch(`http://localhost:${port}/_api/canvas`, {
171
+ method: 'POST',
172
+ headers: { 'Content-Type': 'application/json' },
173
+ body: JSON.stringify({
174
+ name: 'Bad',
175
+ kind: 'video-comp',
176
+ clips: [{ src: '../../etc/passwd', mediaKind: 'video' }],
177
+ }),
178
+ });
179
+ expect(bad.status).toBe(400);
180
+ } finally {
181
+ await killProc(proc);
182
+ }
183
+ });
184
+
185
+ test('rejects a cross-origin create (CSRF guard on /_api/canvas — DDR-150 P4)', async () => {
186
+ const { root } = makeSandbox();
187
+ const port = nextPort();
188
+ const proc = await bootServer(root, port);
189
+ try {
190
+ const r = await fetch(`http://localhost:${port}/_api/canvas`, {
191
+ method: 'POST',
192
+ headers: { 'Content-Type': 'application/json', Origin: 'http://evil.example' },
193
+ body: JSON.stringify({ name: 'Forged', kind: 'brief-board' }),
194
+ });
195
+ expect(r.status).toBe(403);
196
+ } finally {
197
+ await killProc(proc);
198
+ }
199
+ });
200
+
125
201
  test('create + delete emit canvas-list-update over the inspector WS (Phase 30)', async () => {
126
202
  const { root } = makeSandbox();
127
203
  const port = nextPort();
@@ -123,6 +123,59 @@ describe('canvas-edit / applyEdit', () => {
123
123
  expect(out.source).toContain('<div style={{ padding: 14 }}>x</div>');
124
124
  });
125
125
 
126
+ test('retime: bumps a referenced const so a derived total moves in lock-step (DDR-148)', async () => {
127
+ const { applyRetimeSequence } = await import('../canvas-edit.ts');
128
+ const src = [
129
+ 'const A = 66;',
130
+ 'const B = 62;',
131
+ 'const XF = 16;',
132
+ 'const TOTAL = A + B - XF;',
133
+ 'function Movie(){ return (<TransitionSeries>',
134
+ ' <TransitionSeries.Sequence durationInFrames={A}><Intro/></TransitionSeries.Sequence>',
135
+ ' <TransitionSeries.Transition timing={linearTiming({ durationInFrames: XF })} />',
136
+ ' <TransitionSeries.Sequence durationInFrames={B}><Payoff/></TransitionSeries.Sequence>',
137
+ '</TransitionSeries>); }',
138
+ ].join('\n');
139
+ const r0 = applyRetimeSequence(CANVAS, src, 0, { durationInFrames: 80 });
140
+ expect(r0.source).toContain('const A = 80'); // const edited, not the literal
141
+ expect(r0.source).toContain('const TOTAL = A + B - XF'); // derived total intact → auto-updates
142
+ const r1 = applyRetimeSequence(CANVAS, r0.source, 1, { durationInFrames: 90 });
143
+ expect(r1.source).toContain('const B = 90');
144
+ });
145
+
146
+ test('retime: edits a literal in place + supports from', async () => {
147
+ const { applyRetimeSequence } = await import('../canvas-edit.ts');
148
+ const src =
149
+ 'function C(){ return <Sequence from={10} durationInFrames={40}><Foo/></Sequence>; }';
150
+ const r = applyRetimeSequence(CANVAS, src, 0, { from: 20, durationInFrames: 55 });
151
+ expect(r.source).toContain('from={20}');
152
+ expect(r.source).toContain('durationInFrames={55}');
153
+ });
154
+
155
+ test('retime: an out-of-range sequence index throws', async () => {
156
+ const { applyRetimeSequence, CanvasEditError } = await import('../canvas-edit.ts');
157
+ const src = 'function C(){ return <Sequence durationInFrames={40}><Foo/></Sequence>; }';
158
+ expect(() => applyRetimeSequence(CANVAS, src, 5, { durationInFrames: 10 })).toThrow(
159
+ CanvasEditError
160
+ );
161
+ });
162
+
163
+ test('inserts into a style object with a TRAILING comma without a double comma (DDR-148 #4)', () => {
164
+ // A frame-driven inline style commonly ends the last property with a
165
+ // trailing comma. Appending must NOT produce `opacity: o, , color: …`
166
+ // (a syntax error that made the canvas render the OLD source on replay).
167
+ const src =
168
+ 'function Demo() {\n const o = 1;\n return <div style={{ fontSize: 12, opacity: o, }}>x</div>;\n}';
169
+ const ids = idsOf(src);
170
+ const id = ids.div as string;
171
+ const out = applyEdit(CANVAS, src, id, 'style.color', "'#d48917'");
172
+ expect(out.source).toContain("color: '#d48917'");
173
+ expect(out.source).not.toMatch(/,\s*,/); // no double comma
174
+ // And it must still parse.
175
+ const reparse = applyEdit(CANVAS, out.source, id, 'style.fontSize', '13');
176
+ expect(reparse.source).toContain('fontSize: 13');
177
+ });
178
+
126
179
  test('throws CanvasEditError when id is not found', () => {
127
180
  const src = 'function Demo() { return <div>x</div>; }';
128
181
  expect(() => applyEdit(CANVAS, src, 'deadbeef', 'className', 'x')).toThrow(CanvasEditError);
@@ -290,6 +343,30 @@ describe('canvas-edit / applyTextEdit', () => {
290
343
  const src = `function Demo() { return <button>Save</button>; }`;
291
344
  expect(() => applyTextEdit(CANVAS, src, 'deadbeef', 'x')).toThrow(CanvasEditError);
292
345
  });
346
+
347
+ // DDR-150 P1 — widen the editable set to a single `{'string literal'}` child.
348
+ test('edits a single string-literal expression child', () => {
349
+ const src = `function Demo() { return <h1>{'Old Title'}</h1>; }`;
350
+ const id = idsOf(src).h1 as string;
351
+ const out = applyTextEdit(CANVAS, src, id, 'New Title');
352
+ expect(out.source).toBe(`function Demo() { return <h1>{"New Title"}</h1>; }`);
353
+ });
354
+
355
+ test('string-literal rewrite is inert — quotes / braces / markup cannot escape the JS string', () => {
356
+ const src = `function Demo() { return <h1>{"x"}</h1>; }`;
357
+ const id = idsOf(src).h1 as string;
358
+ const out = applyTextEdit(CANVAS, src, id, 'say "hi" {expr} <b>');
359
+ // JSON.stringify escapes the double-quotes; {expr} / <b> stay INSIDE the JS
360
+ // string literal, so they never become a JSX expression or markup.
361
+ expect(out.source).toContain('say \\"hi\\"');
362
+ expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
363
+ });
364
+
365
+ test('still refuses a dynamic {identifier} child (routes to /design:edit)', () => {
366
+ const src = 'function Demo() { const title = "x"; return <h1>{title}</h1>; }';
367
+ const id = idsOf(src).h1 as string;
368
+ expect(() => applyTextEdit(CANVAS, src, id, 'x')).toThrow(CanvasEditError);
369
+ });
293
370
  });
294
371
 
295
372
  // Phase 12 (DDR-103) — the `style.<prop>` write path the CSS knobs ride
@@ -316,4 +393,17 @@ describe('canvas-edit / applyEdit style.<prop> (CSS-knob path)', () => {
316
393
  const out = applyEdit(CANVAS, src, id, 'style.padding', '"var(--space-3)"');
317
394
  expect(out.source).toContain('style={{ padding: "var(--space-3)" }}');
318
395
  });
396
+
397
+ // DDR-150 P1 — the exact video-comp shape: a var-valued prop ending in a
398
+ // trailing comma. Unconditionally prepending ", " produced `opacity: o, ,
399
+ // color` — a syntax error that left the Player rendering stale source (the
400
+ // "my CSS edit resets on replay" bug). Guarded by editStyleProp's between-scan.
401
+ test('appends onto a frame-driven inline style ending in a trailing comma (no double comma)', () => {
402
+ const src = `function Demo() { const o = 1; return <div style={{ opacity: o, }}>x</div>; }`;
403
+ const id = idsOf(src).div as string;
404
+ const out = applyEdit(CANVAS, src, id, 'style.color', '"var(--fg-0)"');
405
+ expect(out.source).not.toContain(', ,');
406
+ expect(out.source).toContain('color: "var(--fg-0)"');
407
+ expect(() => transpileCanvasSource(CANVAS, out.source)).not.toThrow();
408
+ });
319
409
  });
@@ -320,3 +320,52 @@ describe('canvas-list-watch / real fs-watch end-to-end', () => {
320
320
  }
321
321
  }, 10_000);
322
322
  });
323
+
324
+ describe('canvas-list-watch / config hot-reload (stale-config RCA)', () => {
325
+ // /design:setup-ds adds the `system` group to config.json mid-session;
326
+ // reloadConfig (context.ts) swaps ctx.cfg in place. The watcher must read
327
+ // groups at USE time — a boot-captured groupPaths list would keep gating
328
+ // the new group's files out forever.
329
+ // RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
330
+ test('a group added by an in-place cfg swap uncovers its canvases', async () => {
331
+ const { root, designRoot } = sandbox();
332
+ mkdirSync(join(designRoot, 'system', 'kanban-glass', 'preview'), { recursive: true });
333
+ const ctx = mkCtx(designRoot);
334
+ // Pre-scaffold fixture shape: UI-only group.
335
+ ctx.cfg.canvasGroups = [{ label: 'UI', path: 'ui' }];
336
+ const got = collect(ctx);
337
+ const w = createCanvasListWatch(ctx, { debounceMs: 10 });
338
+ await w.ready;
339
+ try {
340
+ // Scaffold writes specimens while the group is still absent — no emit.
341
+ writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'colors.tsx'), TSX);
342
+ ctx.bus.emit('fs:any', 'system/kanban-glass/preview/colors.tsx');
343
+ await new Promise((r) => setTimeout(r, 10 + 40));
344
+ await w.refresh();
345
+ expect(got).toHaveLength(0);
346
+
347
+ // Hot reload: new array on the SAME cfg object (what reloadConfig does),
348
+ // then the server.ts subscriber calls refresh() — the diff must surface
349
+ // the pre-existing specimen.
350
+ ctx.cfg.canvasGroups = [
351
+ { label: 'Design system', path: 'system' },
352
+ { label: 'UI', path: 'ui' },
353
+ ];
354
+ await w.refresh();
355
+ expect(got).toHaveLength(1);
356
+ expect(got[0]?.action).toBe('added');
357
+ expect(got[0]?.rel).toBe('system/kanban-glass/preview/colors.tsx');
358
+
359
+ // And FUTURE writes under the new group pass the live fs:any gate.
360
+ got.length = 0;
361
+ writeFileSync(join(designRoot, 'system', 'kanban-glass', 'preview', 'type.tsx'), TSX);
362
+ ctx.bus.emit('fs:any', 'system/kanban-glass/preview/type.tsx');
363
+ await new Promise((r) => setTimeout(r, 10 + 40));
364
+ await w.refresh();
365
+ expect(got.some((m) => m.action === 'added' && m.rel?.endsWith('type.tsx'))).toBe(true);
366
+ } finally {
367
+ w.stop();
368
+ rmSync(root, { recursive: true, force: true });
369
+ }
370
+ });
371
+ });
@@ -13,6 +13,7 @@ import {
13
13
  isHttpUrl,
14
14
  linkDomain,
15
15
  type MediaPayload,
16
+ mediaSnippet,
16
17
  normalizeUrl,
17
18
  prettifyUrl,
18
19
  } from '../use-canvas-media-drop.tsx';
@@ -116,10 +117,38 @@ describe('use-canvas-media-drop / classifyMediaPayload (dispatch matrix)', () =>
116
117
  expect(intent?.kind).toBe('image');
117
118
  });
118
119
 
119
- test('a non-image file is ignored', () => {
120
+ test('a non-image, non-media file is ignored', () => {
120
121
  expect(classifyMediaPayload({ ...empty, files: [fakeFile('application/pdf')] })).toBeNull();
121
122
  });
122
123
 
124
+ test('a video file → a media intent (DDR-148)', () => {
125
+ const intent = classifyMediaPayload({ ...empty, files: [fakeFile('video/mp4')] });
126
+ expect(intent).toEqual({ kind: 'media', file: expect.anything(), mediaKind: 'video' });
127
+ });
128
+
129
+ test('an audio file → a media intent', () => {
130
+ const intent = classifyMediaPayload({ ...empty, files: [fakeFile('audio/mpeg')] });
131
+ expect(intent?.kind).toBe('media');
132
+ expect(intent && intent.kind === 'media' && intent.mediaKind).toBe('audio');
133
+ });
134
+
135
+ test('an image still wins over a co-dropped video', () => {
136
+ const intent = classifyMediaPayload({
137
+ ...empty,
138
+ files: [fakeFile('image/png'), fakeFile('video/mp4')],
139
+ });
140
+ expect(intent?.kind).toBe('image');
141
+ });
142
+
143
+ test('mediaSnippet emits the right Remotion element', () => {
144
+ expect(mediaSnippet('video', 'assets/ab12cd34.mp4')).toBe(
145
+ '<Video src="assets/ab12cd34.mp4" />'
146
+ );
147
+ expect(mediaSnippet('audio', 'assets/ff00aa11.mp3')).toBe(
148
+ '<Audio src="assets/ff00aa11.mp3" />'
149
+ );
150
+ });
151
+
123
152
  test('a URL drop becomes a link with the anchor text as the title', () => {
124
153
  const intent = classifyMediaPayload({
125
154
  ...empty,