@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
package/README.md CHANGED
@@ -11,7 +11,7 @@ A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude`
11
11
 
12
12
  | Plugin | What it does |
13
13
  | ------ | ------------ |
14
- | **`design`** | Canvas-first iteration on TSX/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. |
14
+ | **`design`** | Canvas-first iteration on TSX/JSX mocks under `.design/` — element selection via Cmd+Click, auto-managed dev server, chained UX/DS critique. Canvases can also be **video-comps** (Remotion compositions previewed in-app, exported to MP4/GIF through Maude's own capture engine — no extra install). |
15
15
  | **`flow`** | Generic agentic workflow loop with a second-brain `.ai/` workspace. `/flow:plan`, `/flow:execute`, `/flow:utils-verify`, `/flow:validate`, `/flow:done`, `/flow:init`, `/flow:record-ddr`, `/flow:scenario`, …. Project-agnostic via `<project>` placeholders + per-repo `.ai/workflows.config.json`. |
16
16
 
17
17
  Plus the **`maude`** CLI — `maude init` scaffolds a fresh `.ai/` workspace from the flow plugin skeleton; `maude design serve` boots the design dev server. The legacy `mdcc` alias still works (prints a deprecation warning) and will be removed in v0.17.x.
@@ -8,7 +8,7 @@
8
8
  // (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
9
9
  // auth precedence falls through to the user's Pro/Max subscription.
10
10
 
11
- import { appendFile, mkdir } from 'node:fs/promises';
11
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { dirname } from 'node:path';
13
13
 
14
14
  import {
@@ -71,6 +71,29 @@ const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
71
71
 
72
72
  export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
73
73
 
74
+ // Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
75
+ // doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
76
+ // shipped despite `_chat/` being gitignored — DDR-115) is rejected rather than
77
+ // forwarded into the privileged `loadSession` ACP call.
78
+ const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
79
+
80
+ // `loadSession`'s replay can, in principle, never settle if the underlying
81
+ // transport dies mid-call (adapter crash, a concurrent `stop()` from another
82
+ // chat sharing this bridge). Bound it so `replaying` always resets and a
83
+ // resume attempt always falls back to `newSession` instead of wedging the
84
+ // bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
85
+ // used for network calls in `apps/studio/git/service.ts`.
86
+ const LOAD_SESSION_TIMEOUT_MS = 15_000;
87
+ const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
88
+ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
89
+ p.catch(() => {});
90
+ let timer: ReturnType<typeof setTimeout>;
91
+ const t = new Promise<typeof TIMED_OUT>((res) => {
92
+ timer = setTimeout(() => res(TIMED_OUT), ms);
93
+ });
94
+ return Promise.race([p, t]).finally(() => clearTimeout(timer));
95
+ }
96
+
74
97
  /**
75
98
  * Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
76
99
  * (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
@@ -90,6 +113,10 @@ export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
90
113
  * must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
91
114
  * acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
92
115
  * Exported for those tests.
116
+ *
117
+ * `cwd`/`mcpServers`/`_meta` are also exactly the shared fields of a
118
+ * `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
119
+ * spreads this same object and adds `sessionId` rather than duplicating it.
93
120
  */
94
121
  export function newSessionParams(
95
122
  repoRoot: string,
@@ -136,12 +163,21 @@ export class AcpBridge {
136
163
  // context. The adapter (one subprocess) holds them all; switching chats reuses
137
164
  // the session, so claude remembers that chat while the app is open.
138
165
  private sessions = new Map<string, string>(); // chatId → sessionId
166
+ // In-flight sessionFor() calls, keyed by chatId — lets a `warm` and a `prompt`
167
+ // racing for the same chat share one resume/create attempt instead of each
168
+ // running establishSession() and stomping the single shared `replaying` flag.
169
+ private sessionPromises = new Map<string, Promise<string>>();
139
170
  private currentSession: string | null = null; // the in-flight prompt's session
140
171
  /** Sessions whose bootstrap brief already hit the transcript (audit record). */
141
172
  private briefLogged = new Set<string>();
142
173
  private starting: Promise<void> | null = null;
143
174
  /** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
144
175
  private transcriptPath: string | null = null;
176
+ /** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
177
+ private sessionStorePath: string | null = null;
178
+ /** True while `conn.loadSession()` is replaying a resumed session's history
179
+ * back through the `sessionUpdate` client callback — see the guard in `start()`. */
180
+ private replaying = false;
145
181
  // Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
146
182
  // change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
147
183
  // is what the running session was spawned with.
@@ -165,6 +201,10 @@ export class AcpBridge {
165
201
  this.transcriptPath = path;
166
202
  }
167
203
 
204
+ setSessionStorePath(path: string | null): void {
205
+ this.sessionStorePath = path;
206
+ }
207
+
168
208
  /** Desired model (alias/id, or null for the user's default) + effort. Applied
169
209
  * on the next prompt — re-spawning the adapter only if it actually changed. */
170
210
  setConfig(model: string | null, effort: AcpEffort): void {
@@ -187,18 +227,104 @@ export class AcpBridge {
187
227
  return this.starting;
188
228
  }
189
229
 
190
- /** Get-or-create the ACP session for a chat id (one claude context per chat). */
230
+ /**
231
+ * Get-or-create the ACP session for a chat id (one claude context per chat).
232
+ * `warm` and `prompt` can both reach this for the same chat close together
233
+ * (composer autocomplete warm-up racing the user hitting send) — a second
234
+ * concurrent call for the same chatId shares the FIRST call's in-flight
235
+ * promise (mirrors `ensureStarted`'s `this.starting` pattern) rather than
236
+ * re-entering resume/create and stomping the single shared `replaying` flag.
237
+ */
191
238
  private async sessionFor(chatId: string): Promise<string> {
192
239
  const existing = this.sessions.get(chatId);
193
240
  if (existing) return existing;
241
+ const inFlight = this.sessionPromises.get(chatId);
242
+ if (inFlight) return inFlight;
243
+
244
+ const promise = this.establishSession(chatId).finally(() => {
245
+ this.sessionPromises.delete(chatId);
246
+ });
247
+ this.sessionPromises.set(chatId, promise);
248
+ return promise;
249
+ }
250
+
251
+ /**
252
+ * Resumes a session persisted from a PRIOR app/dev-server lifetime (the
253
+ * cross-restart memory gap tracked in DDR-125) via the adapter's `loadSession`
254
+ * before falling back to a brand-new `newSession` — either because this chat
255
+ * has never had a session, or because the resume attempt failed (e.g. the
256
+ * underlying claude session was pruned, `claude` was reinstalled, or the
257
+ * adapter's response never arrives — `loadSession` is time-boxed so a dead
258
+ * transport can't wedge `replaying` true forever and silently black-hole
259
+ * every future turn on this bridge).
260
+ */
261
+ private async establishSession(chatId: string): Promise<string> {
194
262
  if (!this.conn) throw new Error('ACP adapter not started');
195
- const created = await this.conn.newSession(
196
- newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins)
197
- );
263
+
264
+ const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
265
+ const persistedId = await this.readPersistedSessionId();
266
+ if (persistedId) {
267
+ try {
268
+ this.replaying = true;
269
+ const result = await withTimeout(
270
+ this.conn.loadSession({ ...params, sessionId: persistedId }),
271
+ LOAD_SESSION_TIMEOUT_MS
272
+ );
273
+ if (result === TIMED_OUT) {
274
+ throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
275
+ }
276
+ this.sessions.set(chatId, persistedId);
277
+ return persistedId;
278
+ } catch (err) {
279
+ await this.appendTranscript({
280
+ role: 'bootstrap',
281
+ kind: 'resume-failed',
282
+ error: err instanceof Error ? err.message : String(err),
283
+ });
284
+ } finally {
285
+ this.replaying = false;
286
+ }
287
+ }
288
+
289
+ const created = await this.conn.newSession(params);
198
290
  this.sessions.set(chatId, created.sessionId);
291
+ await this.writePersistedSessionId(created.sessionId);
199
292
  return created.sessionId;
200
293
  }
201
294
 
295
+ /** Read the sessionId persisted for this chat by a prior bridge lifetime.
296
+ * Null when there's no sidecar wired (e.g. warm-up before any prompt), the
297
+ * file doesn't exist yet (first-ever turn), it's unreadable/corrupt, or its
298
+ * `sessionId` doesn't look like a real one (defense-in-depth — this file's
299
+ * directory is per-machine/gitignored per DDR-115, but a cloned repo could
300
+ * still ship a tracked file there, so bound what we'll forward into the
301
+ * privileged `loadSession` ACP call rather than trusting its shape blindly). */
302
+ private async readPersistedSessionId(): Promise<string | null> {
303
+ if (!this.sessionStorePath) return null;
304
+ try {
305
+ const raw = await readFile(this.sessionStorePath, 'utf8');
306
+ const data = JSON.parse(raw) as { sessionId?: unknown };
307
+ const id = data.sessionId;
308
+ return typeof id === 'string' && VALID_SESSION_ID.test(id) ? id : null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /** Persist a freshly-created sessionId so the NEXT bridge lifetime (app
315
+ * restart, dev-server restart) can resume this chat instead of starting
316
+ * fresh. Best-effort, like `appendTranscript` — a failed write just means
317
+ * the next restart falls back to a new session. */
318
+ private async writePersistedSessionId(sessionId: string): Promise<void> {
319
+ if (!this.sessionStorePath) return;
320
+ try {
321
+ await mkdir(dirname(this.sessionStorePath), { recursive: true });
322
+ await writeFile(this.sessionStorePath, JSON.stringify({ sessionId, updatedAt: Date.now() }));
323
+ } catch {
324
+ /* best-effort — see doc comment above */
325
+ }
326
+ }
327
+
202
328
  private async start(): Promise<void> {
203
329
  const adapterEntry = resolveAdapterEntry();
204
330
  if (!adapterEntry) {
@@ -279,6 +405,12 @@ export class AcpBridge {
279
405
  this.opts.onCommands?.(params.update.availableCommands ?? []);
280
406
  return;
281
407
  }
408
+ // `loadSession` replays the resumed session's entire prior history back
409
+ // through this SAME callback (claude-agent-acp's replaySessionHistory) to
410
+ // prime its own in-adapter state. That history is already on disk in the
411
+ // transcript and already rendered client-side, so forwarding/re-appending
412
+ // it here would duplicate every message in the panel and the jsonl file.
413
+ if (this.replaying) return;
282
414
  this.opts.onUpdate(params.update);
283
415
  void this.appendTranscript({ role: 'agent', update: params.update });
284
416
  },
@@ -388,6 +520,13 @@ export class AcpBridge {
388
520
  this.proc = null;
389
521
  this.conn = null;
390
522
  this.sessions.clear();
523
+ // Drop any in-flight sessionFor() promises too — they were bound to the
524
+ // now-dead `conn`; a subsequent sessionFor() for the same chatId must
525
+ // establish fresh against the respawned connection, not await a stale
526
+ // reference (each entry's own .finally() would eventually clear it once its
527
+ // bounded loadSession timeout fires, but a call landing before then would
528
+ // otherwise get back a result tied to the connection we just tore down).
529
+ this.sessionPromises.clear();
391
530
  this.briefLogged.clear();
392
531
  this.currentSession = null;
393
532
  }
@@ -185,8 +185,16 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
185
185
  const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
186
186
  return safe || 'default';
187
187
  }
188
+ function chatFilePathFor(chatId: string, suffix: string): string {
189
+ return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}${suffix}`);
190
+ }
188
191
  function transcriptPathFor(chatId: string): string {
189
- return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}.jsonl`);
192
+ return chatFilePathFor(chatId, '.jsonl');
193
+ }
194
+ // Sidecar persisting this chat's ACP sessionId across restarts (bridge.ts
195
+ // sessionFor's resume path) — the cross-restart memory gap tracked in DDR-125.
196
+ function sessionStorePathFor(chatId: string): string {
197
+ return chatFilePathFor(chatId, '.session.json');
190
198
  }
191
199
 
192
200
  async function handlePrompt(
@@ -198,6 +206,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
198
206
  ): Promise<void> {
199
207
  const bridge = getOrCreateBridge(ws);
200
208
  bridge.setTranscriptPath(transcriptPathFor(chatId));
209
+ bridge.setSessionStorePath(sessionStorePathFor(chatId));
201
210
  bridge.setConfig(model, effort);
202
211
  try {
203
212
  await bridge.ensureStarted();
@@ -226,6 +235,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
226
235
  effort: AcpEffort
227
236
  ): Promise<void> {
228
237
  const bridge = getOrCreateBridge(ws);
238
+ bridge.setSessionStorePath(sessionStorePathFor(chatId));
229
239
  bridge.setConfig(model, effort);
230
240
  try {
231
241
  await bridge.warmUp(sanitizeChatId(chatId));
@@ -827,9 +827,52 @@ export function AnnotationContextToolbar({
827
827
  // fall through to the generic bar below.
828
828
  const soleMedia =
829
829
  selectedStrokes.length === 1 &&
830
- (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'link')
830
+ (selectedStrokes[0]?.tool === 'image' ||
831
+ selectedStrokes[0]?.tool === 'link' ||
832
+ selectedStrokes[0]?.tool === 'mediaref')
831
833
  ? selectedStrokes[0]
832
834
  : null;
835
+ if (soleMedia?.tool === 'mediaref') {
836
+ // DDR-150 dogfood — a media-reference chip has no color to set (the generic
837
+ // swatch palette was meaningless noise). Focused panel: filename + delete.
838
+ const mr = soleMedia;
839
+ return (
840
+ <div
841
+ ref={ref}
842
+ className="dc-annot-ctx"
843
+ role="toolbar"
844
+ aria-label="Media reference properties"
845
+ style={{ display: 'flex', top: -9999, left: -9999 }}
846
+ >
847
+ <span
848
+ className="dc-annot-ctx-label"
849
+ style={{
850
+ padding: '0 8px',
851
+ fontSize: 12,
852
+ opacity: 0.85,
853
+ maxWidth: 220,
854
+ overflow: 'hidden',
855
+ textOverflow: 'ellipsis',
856
+ whiteSpace: 'nowrap',
857
+ }}
858
+ title={`${mr.mediaKind} · ${mr.src}`}
859
+ >
860
+ {mr.mediaKind === 'audio' ? '♪ ' : '▶ '}
861
+ {mr.title}
862
+ </span>
863
+ <div className="dc-annot-ctx-sep" />
864
+ <button
865
+ type="button"
866
+ className="dc-annot-ctx-ibtn dc-annot-ctx-ibtn--danger"
867
+ aria-label="Delete media reference"
868
+ title="Delete"
869
+ onClick={remove}
870
+ >
871
+ <IconTrash />
872
+ </button>
873
+ </div>
874
+ );
875
+ }
833
876
  if (soleMedia?.tool === 'image') {
834
877
  const img = soleMedia;
835
878
  const commitAlt = (value: string) => {
@@ -93,6 +93,12 @@ import {
93
93
  linkCardLayout,
94
94
  listPrefixedBody,
95
95
  listPrefixedLine,
96
+ MEDIAREF_AUDIO_GLYPH,
97
+ MEDIAREF_DEFAULT_H,
98
+ MEDIAREF_DEFAULT_W,
99
+ MEDIAREF_VIDEO_GLYPH,
100
+ MEDIAREF_VIDEO_H,
101
+ type MediaRefStroke,
96
102
  normalizeBox,
97
103
  normalizeRect,
98
104
  normalizeSticky,
@@ -380,6 +386,19 @@ function resolveAssetHref(href: string): string {
380
386
  return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
381
387
  }
382
388
 
389
+ /**
390
+ * DDR-150 dogfood #8 — true when a pointer/click event targets the inline
391
+ * media player inside a mediaref chip. Every document-capture annotation
392
+ * handler early-returns on it, so the player's native controls (and its
393
+ * click-to-toggle) work instead of starting a stroke select/drag/draw.
394
+ * (NOT a stopPropagation guard — that would also kill React's delegated
395
+ * listeners, which attach later on the capture path.)
396
+ */
397
+ function isMediaPlayerTarget(e: Event): boolean {
398
+ const t = e.target as Element | null;
399
+ return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
400
+ }
401
+
383
402
  // ─────────────────────────────────────────────────────────────────────────────
384
403
  // Styles
385
404
 
@@ -1167,15 +1186,63 @@ export function AnnotationsLayer() {
1167
1186
  [commitStrokes, annotSel]
1168
1187
  );
1169
1188
 
1189
+ // Media reference (DDR-150 P4): a video/audio file dropped on the canvas BODY
1190
+ // becomes a versioned reference chip (NOT a source insert, NOT a played
1191
+ // element) carrying its assets/ path — the "nahazet klipy → agent z toho udělá
1192
+ // video" artifact. Upload to assets/, then commit a MediaRefStroke; on failure
1193
+ // toast. No poster probe in v1 — a media glyph tile (▶/♪) + filename.
1194
+ const createMediaReference = useCallback(
1195
+ (file: File, mediaKind: 'video' | 'audio', world: [number, number]) => {
1196
+ const w = MEDIAREF_DEFAULT_W;
1197
+ // Video chips are taller — they host the inline 16:9 player (dogfood #8).
1198
+ const h = mediaKind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
1199
+ void uploadAsset(file).then((res) => {
1200
+ if (!('path' in res)) {
1201
+ showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
1202
+ return;
1203
+ }
1204
+ const id = rid();
1205
+ const ref: MediaRefStroke = {
1206
+ id,
1207
+ tool: 'mediaref',
1208
+ x: world[0] - w / 2,
1209
+ y: world[1] - h / 2,
1210
+ w,
1211
+ h,
1212
+ src: res.path,
1213
+ mediaKind,
1214
+ title: (file.name || res.path).slice(0, 300),
1215
+ };
1216
+ const before = strokesRef.current;
1217
+ commitStrokes(before, [...before, ref], `add ${mediaKind} reference`);
1218
+ annotSel?.replace([id]);
1219
+ const sizeMb = file.size / (1024 * 1024);
1220
+ showCanvasToast(
1221
+ `Added ${mediaKind} reference · ${res.path}${sizeMb > 20 ? ' · ⚠ >20 MB rides git + sync' : ''}`
1222
+ );
1223
+ });
1224
+ },
1225
+ [commitStrokes, annotSel]
1226
+ );
1227
+
1170
1228
  const mediaCallbacks = useMemo(
1171
- () => ({ onImage: createImageFromFile, onLink: createLink }),
1172
- [createImageFromFile, createLink]
1229
+ () => ({ onImage: createImageFromFile, onLink: createLink, onMedia: createMediaReference }),
1230
+ [createImageFromFile, createLink, createMediaReference]
1173
1231
  );
1174
1232
  // Media intake is paste/drop only (per product steer — no toolbar buttons):
1175
1233
  // drop an image / URL or Cmd+V a clipboard image / link straight onto the
1176
1234
  // canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
1177
1235
  // hold the commit/undo sink + screenToWorld.
1178
- useCanvasMediaDrop({ enabled: visible, screenToWorld, callbacks: mediaCallbacks });
1236
+ //
1237
+ // DDR-150 dogfood #8 — intake was gated on `visible` (annotations toggled on),
1238
+ // so with annotations hidden (⇧P) a Finder drop silently did NOTHING. Intake
1239
+ // now stays live whenever we're not presenting; the committed stroke simply
1240
+ // shows once annotations are visible again.
1241
+ useCanvasMediaDrop({
1242
+ enabled: !(chrome?.present ?? false),
1243
+ screenToWorld,
1244
+ callbacks: mediaCallbacks,
1245
+ });
1179
1246
 
1180
1247
  const eraseAt = useCallback(
1181
1248
  (wx: number, wy: number) => {
@@ -1636,6 +1703,7 @@ export function AnnotationsLayer() {
1636
1703
  t === 'sticky' ||
1637
1704
  t === 'image' ||
1638
1705
  t === 'link' ||
1706
+ t === 'mediaref' ||
1639
1707
  t === 'section')
1640
1708
  ) {
1641
1709
  return id;
@@ -1644,6 +1712,7 @@ export function AnnotationsLayer() {
1644
1712
  };
1645
1713
 
1646
1714
  const onDown = (e: PointerEvent) => {
1715
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
1647
1716
  if (e.button !== 0) return;
1648
1717
  if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
1649
1718
  const target = e.target as Element | null;
@@ -1937,6 +2006,7 @@ export function AnnotationsLayer() {
1937
2006
  if (typeof document === 'undefined') return;
1938
2007
  if (tool !== 'move') return;
1939
2008
  const onDbl = (e: MouseEvent) => {
2009
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
1940
2010
  const target = e.target as Element | null;
1941
2011
  const node = target?.closest?.('[data-id][data-tool]');
1942
2012
  if (!node) return;
@@ -2460,6 +2530,7 @@ export function AnnotationsLayer() {
2460
2530
  if (typeof document === 'undefined') return;
2461
2531
  if (tool !== 'move') return;
2462
2532
  const onDown = (e: PointerEvent) => {
2533
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2463
2534
  if (e.button !== 0) return;
2464
2535
  const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
2465
2536
  if (!dot) return;
@@ -2575,6 +2646,7 @@ export function AnnotationsLayer() {
2575
2646
  // propagation WITHOUT preventDefault, so the native contextmenu event
2576
2647
  // (which opens OUR menu above) still follows.
2577
2648
  const onDown = (e: PointerEvent) => {
2649
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2578
2650
  if (e.button !== 2) return;
2579
2651
  if (!strokeAt(e.target as Element | null)) return;
2580
2652
  e.stopImmediatePropagation();
@@ -2724,6 +2796,10 @@ export function AnnotationsLayer() {
2724
2796
  onCancelEdit={cancelEditing}
2725
2797
  />
2726
2798
  ) : null}
2799
+ {/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
2800
+ overlay in the world div; see MediaRefPlayers for why not
2801
+ foreignObject). */}
2802
+ <MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
2727
2803
  <AnnotationContextToolbar
2728
2804
  editingId={
2729
2805
  editingTarget?.kind === 'anchored'
@@ -3110,6 +3186,92 @@ function AnnotationsSvg({
3110
3186
  );
3111
3187
  }
3112
3188
 
3189
+ /**
3190
+ * DDR-150 dogfood #8 — the mediaref chips' inline players, rendered as PLAIN
3191
+ * HTML absolutely positioned in the world div (which carries the pan/zoom CSS
3192
+ * transform), NOT as SVG foreignObject: Chromium hit-tests foreignObject
3193
+ * content under a transformed ancestor in the un-transformed coordinate space,
3194
+ * so real clicks miss the player at most zoom levels. HTML children of the
3195
+ * transformed div hit-test correctly. The [data-mediaref-player] attr keeps
3196
+ * every document-capture annotation handler out (isMediaPlayerTarget guard).
3197
+ */
3198
+ function MediaRefPlayers({
3199
+ worldRef,
3200
+ strokes,
3201
+ visible,
3202
+ }: {
3203
+ worldRef: React.RefObject<HTMLElement | null>;
3204
+ strokes: readonly Stroke[];
3205
+ visible: boolean;
3206
+ }) {
3207
+ const target = worldRef.current;
3208
+ if (!target || !visible) return null;
3209
+ const HEADER = 26;
3210
+ const refs = strokes.filter(
3211
+ (s): s is MediaRefStroke => s.tool === 'mediaref' && !!s.src && Math.abs(s.h) > HEADER + 12
3212
+ );
3213
+ if (refs.length === 0) return null;
3214
+ return createPortal(
3215
+ <>
3216
+ {refs.map((s) => {
3217
+ const x = Math.min(s.x, s.x + s.w);
3218
+ const y = Math.min(s.y, s.y + s.h);
3219
+ const w = Math.abs(s.w);
3220
+ const h = Math.abs(s.h);
3221
+ const mediaUrl = resolveAssetHref(s.src);
3222
+ const isAudio = s.mediaKind === 'audio';
3223
+ return (
3224
+ <div
3225
+ key={`mrp-${s.id}`}
3226
+ data-mediaref-player="1"
3227
+ style={{
3228
+ position: 'absolute',
3229
+ left: x + 4,
3230
+ top: y + HEADER,
3231
+ width: Math.max(8, w - 8),
3232
+ height: Math.max(8, h - HEADER - 4),
3233
+ borderRadius: 6,
3234
+ overflow: 'hidden',
3235
+ zIndex: 4,
3236
+ }}
3237
+ >
3238
+ {isAudio ? (
3239
+ // biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — there is no caption source to point a <track> at.
3240
+ <audio
3241
+ controls
3242
+ preload="metadata"
3243
+ src={mediaUrl}
3244
+ style={{ width: '100%', height: '100%' }}
3245
+ />
3246
+ ) : (
3247
+ // biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — no caption source exists for an arbitrary dragged-in clip.
3248
+ <video
3249
+ // Chromium's native controls already toggle play/pause on a
3250
+ // content-area click (a custom click listener would double-fire
3251
+ // against it and cancel itself out — verified live). The router
3252
+ // overlay-skip + the annotation-handler guards are what make
3253
+ // these native interactions reachable.
3254
+ controls
3255
+ preload="metadata"
3256
+ playsInline
3257
+ src={mediaUrl}
3258
+ style={{
3259
+ width: '100%',
3260
+ height: '100%',
3261
+ objectFit: 'contain',
3262
+ background: '#000',
3263
+ display: 'block',
3264
+ }}
3265
+ />
3266
+ )}
3267
+ </div>
3268
+ );
3269
+ })}
3270
+ </>,
3271
+ target
3272
+ );
3273
+ }
3274
+
3113
3275
  function TextEditor({
3114
3276
  anchorId,
3115
3277
  host,
@@ -3165,6 +3327,7 @@ function TextEditor({
3165
3327
  useEffect(() => {
3166
3328
  if (typeof document === 'undefined') return;
3167
3329
  const onDown = (e: PointerEvent) => {
3330
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3168
3331
  const el = ref.current;
3169
3332
  if (!el) return;
3170
3333
  if (el.contains(e.target as Node)) return;
@@ -3427,6 +3590,7 @@ function StandaloneTextEditor({
3427
3590
  useEffect(() => {
3428
3591
  if (typeof document === 'undefined') return;
3429
3592
  const onDown = (e: PointerEvent) => {
3593
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3430
3594
  const el = ref.current;
3431
3595
  if (!el) return;
3432
3596
  if (el.contains(e.target as Node)) return;
@@ -3609,6 +3773,7 @@ function AnnotationContextMenu({
3609
3773
  if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
3610
3774
  el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
3611
3775
  const onDown = (e: PointerEvent) => {
3776
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3612
3777
  if (!el.contains(e.target as Node)) onClose();
3613
3778
  };
3614
3779
  const onKey = (e: KeyboardEvent) => {
@@ -4176,6 +4341,91 @@ function StrokeNodeBase({
4176
4341
  </g>
4177
4342
  );
4178
4343
  }
4344
+ if (stroke.tool === 'mediaref') {
4345
+ // DDR-150 P4 + dogfood #8 — reference chip with a REAL inline player.
4346
+ // LIVE-RENDER ONLY: the <foreignObject> + <video>/<audio> below never
4347
+ // persist — the model serializer still writes the sanitizer-safe data-*
4348
+ // card (foreignObject is stripped by sanitizeAnnotationSvg by design).
4349
+ // The 26px header strip (badge + filename) is the select/drag handle; the
4350
+ // player area is fenced off from the annotation handlers by the
4351
+ // [data-mediaref-player] window-capture guard.
4352
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4353
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4354
+ const w = Math.abs(stroke.w);
4355
+ const h = Math.abs(stroke.h);
4356
+ const HEADER = 26;
4357
+ const isAudio = stroke.mediaKind === 'audio';
4358
+ const mediaUrl = stroke.src ? resolveAssetHref(stroke.src) : '';
4359
+ const shownTitle = clampLinkTitle(stroke.title, Math.max(8, Math.floor((w - 40) / 7)));
4360
+ const textFont = {
4361
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4362
+ } as const;
4363
+ return (
4364
+ <g
4365
+ data-id={stroke.id}
4366
+ data-tool="mediaref"
4367
+ data-src={stroke.src}
4368
+ data-media-kind={stroke.mediaKind}
4369
+ data-title={stroke.title}
4370
+ pointerEvents={hitMode}
4371
+ >
4372
+ <rect
4373
+ x={x}
4374
+ y={y}
4375
+ width={w}
4376
+ height={h}
4377
+ rx={8}
4378
+ ry={8}
4379
+ fill={LINK_CARD_FILL}
4380
+ stroke={LINK_CARD_STROKE}
4381
+ strokeWidth={1}
4382
+ vectorEffect="non-scaling-stroke"
4383
+ filter="url(#dc-sticky-shadow)"
4384
+ />
4385
+ <svg
4386
+ x={x + 8}
4387
+ y={y + 5}
4388
+ width={16}
4389
+ height={16}
4390
+ viewBox="0 0 24 24"
4391
+ fill={LINK_GLYPH_STROKE}
4392
+ stroke="none"
4393
+ aria-hidden="true"
4394
+ >
4395
+ <path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
4396
+ </svg>
4397
+ <text
4398
+ x={x + 30}
4399
+ y={y + 9}
4400
+ fontSize={11}
4401
+ fill={LINK_TITLE_FILL}
4402
+ fontWeight={600}
4403
+ dominantBaseline="hanging"
4404
+ style={textFont}
4405
+ >
4406
+ {shownTitle}
4407
+ </text>
4408
+ {/* The inline player itself is an HTML overlay portaled beside this SVG
4409
+ (MediaRefPlayers below) — NOT a foreignObject: Chromium hit-tests
4410
+ foreignObject content under a CSS-transformed ancestor in the WRONG
4411
+ coordinate space (the un-panned/un-zoomed one), so real clicks miss
4412
+ the player at most zoom levels while elementFromPoint lies that
4413
+ they'd land. Plain HTML in the transformed world hit-tests right. */}
4414
+ {!mediaUrl ? (
4415
+ <text
4416
+ x={x + 30}
4417
+ y={y + HEADER + 10}
4418
+ fontSize={10}
4419
+ fill={LINK_DOMAIN_FILL}
4420
+ dominantBaseline="hanging"
4421
+ style={textFont}
4422
+ >
4423
+ (missing media reference)
4424
+ </text>
4425
+ ) : null}
4426
+ </g>
4427
+ );
4428
+ }
4179
4429
  if (stroke.tool === 'section') {
4180
4430
  const x = Math.min(stroke.x, stroke.x + stroke.w);
4181
4431
  const y = Math.min(stroke.y, stroke.y + stroke.h);