@1agh/maude 0.39.1 → 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 (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. 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.
@@ -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,
@@ -67,6 +67,25 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
67
67
  '/_api/edit-css',
68
68
  '/_api/edit-text',
69
69
  '/_api/edit-attr',
70
+ // DDR-148 — raw canvas source for the Timeline parser is MAIN-ORIGIN
71
+ // ONLY (the untrusted canvas iframe must never read project source).
72
+ '/_api/canvas-source',
73
+ // DDR-148 — Timeline drag-to-retime is a source-write, MAIN-ORIGIN ONLY.
74
+ '/_api/retime-sequence',
75
+ // DDR-150 P2 — the clip enumerator reads project source structure, so it
76
+ // is MAIN-ORIGIN ONLY (same boundary as /_api/canvas-source): the
77
+ // untrusted canvas iframe must never enumerate the comp it renders.
78
+ '/_api/comp-clips',
79
+ // DDR-150 P3 — clip removal is a source-write, MAIN-ORIGIN ONLY.
80
+ '/_api/remove-sequence',
81
+ // DDR-150 P4 — clip insert is a source-write, MAIN-ORIGIN ONLY.
82
+ '/_api/insert-sequence',
83
+ // DDR-150 P5 — z-order reorder is a source-write, MAIN-ORIGIN ONLY.
84
+ '/_api/reorder-sequence',
85
+ // DDR-150 dogfood — array-src replace is a source-write, MAIN-ORIGIN ONLY.
86
+ '/_api/edit-array-src',
87
+ // DDR-150 dogfood — clip hide/show is a source-write, MAIN-ORIGIN ONLY.
88
+ '/_api/toggle-hide',
70
89
  // Phase 12.1 (DDR-138) — node-move reorder is a source-write, MAIN-ORIGIN
71
90
  // ONLY. The canvas iframe requests a reorder over the dgn:* bus; the shell
72
91
  // performs the write. A GET here 403s at the gate (route unreachable on
@@ -141,9 +160,26 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
141
160
  const shell = await fetch(`${canvas}/_canvas-shell.html`);
142
161
  const csp = shell.headers.get('content-security-policy') ?? '';
143
162
  expect(csp).toContain("connect-src 'self'");
163
+ // DDR-148 — media-src must be present so a video-comp's <Video>/<Audio>
164
+ // isn't blocked by default-src 'none' (black video / silent audio).
165
+ expect(csp).toContain("media-src 'self'");
144
166
  expect(csp).toContain("webrtc 'block'");
145
167
  expect(csp).toContain('frame-ancestors');
146
168
  expect(csp).toContain(`http://localhost:${port}`);
169
+
170
+ // (5) DDR-148 attacker F1 — the EXPORT/CAPTURE render (?hide-chrome=1) on
171
+ // the MAIN origin (where the normal shell CSP is off) MUST carry the
172
+ // network-locked capture CSP, else a canvas <Video src="http://internal">
173
+ // or comp fetch() turns ⌘E into read-SSRF + exfil-via-artifact.
174
+ const capMain = await fetch(`http://localhost:${port}/_canvas-shell.html?hide-chrome=1`);
175
+ const capCsp = capMain.headers.get('content-security-policy') ?? '';
176
+ expect(capCsp).toContain("media-src 'self'"); // <Video src=external> blocked
177
+ expect(capCsp).toContain("connect-src 'self'"); // comp fetch(external) blocked
178
+ expect(capCsp).toContain("img-src 'self'");
179
+ expect(capCsp).toContain("object-src 'none'");
180
+ // A normal main-origin shell (no capture flag) stays CSP-off (back-compat).
181
+ const plainMain = await fetch(`http://localhost:${port}/_canvas-shell.html`);
182
+ expect(plainMain.headers.get('content-security-policy')).toBeNull();
147
183
  } finally {
148
184
  await killProc(proc);
149
185
  }