@1agh/maude 0.39.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +2 -2
  3. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  4. package/apps/studio/annotations-layer.tsx +306 -5
  5. package/apps/studio/annotations-model.ts +107 -6
  6. package/apps/studio/api.ts +1316 -67
  7. package/apps/studio/artboard-marquee.tsx +1 -1
  8. package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
  9. package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
  10. package/apps/studio/bin/_fetch-asset.mjs +556 -0
  11. package/apps/studio/bin/_html-playwright.mjs +9 -1
  12. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  13. package/apps/studio/bin/_png-playwright.mjs +8 -1
  14. package/apps/studio/bin/_pw-launch.mjs +54 -0
  15. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  16. package/apps/studio/bin/_video-playwright.mjs +452 -0
  17. package/apps/studio/bin/annotate.mjs +576 -34
  18. package/apps/studio/bin/canvas-rects.sh +152 -0
  19. package/apps/studio/bin/fetch-asset.sh +34 -0
  20. package/apps/studio/bin/prep.sh +8 -1
  21. package/apps/studio/bin/read-annotations.mjs +138 -7
  22. package/apps/studio/bin/smoke.sh +42 -6
  23. package/apps/studio/build.ts +21 -0
  24. package/apps/studio/canvas-comment-mount.tsx +138 -4
  25. package/apps/studio/canvas-edit.ts +2625 -111
  26. package/apps/studio/canvas-lib.tsx +238 -2
  27. package/apps/studio/canvas-list-watch.ts +6 -2
  28. package/apps/studio/canvas-shell.tsx +514 -20
  29. package/apps/studio/client/app.jsx +2480 -52
  30. package/apps/studio/client/comments-overlay.css +130 -126
  31. package/apps/studio/client/github.js +15 -0
  32. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  33. package/apps/studio/client/panels/timeline-parse.js +229 -0
  34. package/apps/studio/client/panels/timeline-snap.js +55 -0
  35. package/apps/studio/client/styles/3-shell-maude.css +155 -0
  36. package/apps/studio/comments-overlay.tsx +148 -41
  37. package/apps/studio/config.schema.json +14 -0
  38. package/apps/studio/context-menu.tsx +16 -6
  39. package/apps/studio/context.ts +113 -1
  40. package/apps/studio/contextual-toolbar.tsx +262 -4
  41. package/apps/studio/cursors-overlay.tsx +4 -4
  42. package/apps/studio/dist/client.bundle.js +89 -17
  43. package/apps/studio/dist/comment-mount.js +59 -1
  44. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  45. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  46. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  47. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  48. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  49. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  50. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  51. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  52. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  53. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  54. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  55. package/apps/studio/dist/runtime/remotion.js +42 -0
  56. package/apps/studio/dist/styles.css +1 -1
  57. package/apps/studio/dom-selection.ts +127 -1
  58. package/apps/studio/drag-state.ts +24 -0
  59. package/apps/studio/equal-spacing-detector.ts +205 -0
  60. package/apps/studio/export-dialog.tsx +1 -1
  61. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  62. package/apps/studio/exporters/_runtime.ts +69 -0
  63. package/apps/studio/exporters/html.ts +4 -3
  64. package/apps/studio/exporters/index.ts +28 -2
  65. package/apps/studio/exporters/pdf.ts +4 -3
  66. package/apps/studio/exporters/png.ts +5 -3
  67. package/apps/studio/exporters/pptx.ts +6 -4
  68. package/apps/studio/exporters/svg.ts +9 -5
  69. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  70. package/apps/studio/exporters/video-render-lib.ts +108 -0
  71. package/apps/studio/exporters/video.ts +184 -0
  72. package/apps/studio/history.ts +47 -1
  73. package/apps/studio/http.ts +758 -27
  74. package/apps/studio/input-router.tsx +19 -1
  75. package/apps/studio/marquee-overlay.tsx +1 -1
  76. package/apps/studio/measure-overlay.tsx +241 -0
  77. package/apps/studio/participants-chrome.tsx +3 -3
  78. package/apps/studio/runtime-bundle.ts +30 -0
  79. package/apps/studio/server.ts +34 -9
  80. package/apps/studio/sizing-mode.ts +117 -0
  81. package/apps/studio/spacing-handles.ts +166 -0
  82. package/apps/studio/test/annotate-write.test.ts +890 -0
  83. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  84. package/apps/studio/test/camera-reveal.test.tsx +173 -0
  85. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  86. package/apps/studio/test/canvas-edit.test.ts +140 -0
  87. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  88. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  89. package/apps/studio/test/canvas-origin-gate.test.ts +54 -0
  90. package/apps/studio/test/canvas-rects.test.ts +198 -0
  91. package/apps/studio/test/clip-addressing.test.ts +732 -0
  92. package/apps/studio/test/comments-overlay.test.ts +117 -0
  93. package/apps/studio/test/config-reload.test.ts +230 -0
  94. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  95. package/apps/studio/test/dom-selection.test.ts +130 -0
  96. package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
  97. package/apps/studio/test/edit-persistence.test.ts +91 -0
  98. package/apps/studio/test/edit-scope-api.test.ts +115 -0
  99. package/apps/studio/test/element-resize.test.ts +136 -0
  100. package/apps/studio/test/element-structural-api.test.ts +360 -0
  101. package/apps/studio/test/element-structural-edit.test.ts +233 -0
  102. package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
  103. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  104. package/apps/studio/test/file-lock.test.ts +84 -0
  105. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  106. package/apps/studio/test/history-rollback.test.ts +26 -0
  107. package/apps/studio/test/knob-props-authored.test.ts +87 -0
  108. package/apps/studio/test/read-annotations.test.ts +154 -0
  109. package/apps/studio/test/sizing-mode.test.ts +102 -0
  110. package/apps/studio/test/spacing-handles.test.ts +138 -0
  111. package/apps/studio/test/specimen-select.test.ts +88 -0
  112. package/apps/studio/test/timeline-parse.test.ts +127 -0
  113. package/apps/studio/test/timeline-snap.test.ts +85 -0
  114. package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
  115. package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
  116. package/apps/studio/test/video-asset.test.ts +163 -0
  117. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  118. package/apps/studio/test/video-comp.test.ts +168 -0
  119. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  120. package/apps/studio/tool-palette.tsx +122 -2
  121. package/apps/studio/undo-hud.tsx +2 -2
  122. package/apps/studio/use-annotation-resize.tsx +6 -3
  123. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  124. package/apps/studio/use-element-resize.tsx +732 -0
  125. package/apps/studio/use-keyboard-discipline.tsx +205 -15
  126. package/apps/studio/use-selection-set.tsx +14 -0
  127. package/apps/studio/use-spacing-handles.tsx +388 -0
  128. package/apps/studio/video-comp.tsx +444 -0
  129. package/apps/studio/whats-new.json +55 -0
  130. package/apps/studio/ws.ts +5 -0
  131. package/cli/commands/design.mjs +6 -1
  132. package/cli/commands/design.test.mjs +49 -1
  133. package/cli/lib/fetch-asset.test.mjs +213 -0
  134. package/package.json +8 -8
  135. package/plugins/design/dependencies.json +10 -2
  136. package/plugins/design/templates/_shell.html +25 -2
  137. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
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.
@@ -43,9 +43,9 @@ const BANNER_CSS = `
43
43
  background: var(--maude-hud-accent-tint, oklch(92% 0.040 55));
44
44
  color: var(--maude-chrome-fg-0, #111);
45
45
  border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
46
- border-radius: var(--radius-md, 4px);
46
+ border-radius: 4px;
47
47
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
48
- font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
48
+ font-family: system-ui, -apple-system, sans-serif;
49
49
  font-weight: 500;
50
50
  font-size: 12px;
51
51
  line-height: 1.2;
@@ -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
 
@@ -1057,11 +1076,36 @@ export function AnnotationsLayer() {
1057
1076
  }
1058
1077
  if (m.dgn === 'annotation-select-all') {
1059
1078
  if (annotSel) annotSel.replace(strokes.map((s) => s.id));
1079
+ return;
1080
+ }
1081
+ // Stage F3 — the shell's AssetPicker (main-origin, DDR-054) resolved a new
1082
+ // asset for an ImageStroke/MediaRefStroke's "Replace…" context-menu entry;
1083
+ // the canvas iframe owns the annotation model, so the shell REQUESTS the
1084
+ // swap here rather than writing strokes itself. `href`/`src` mirrors each
1085
+ // stroke shape (ImageStroke.href / MediaRefStroke.src) — swap whichever key
1086
+ // the target stroke actually carries, everything else survives byte-for-byte.
1087
+ if (
1088
+ m.dgn === 'replace-annotation-media' &&
1089
+ typeof (m as { id?: unknown }).id === 'string' &&
1090
+ typeof (m as { path?: unknown }).path === 'string'
1091
+ ) {
1092
+ const id = (m as { id: string }).id;
1093
+ const path = (m as { path: string }).path;
1094
+ const before = strokesRef.current;
1095
+ const target = before.find((s) => s.id === id);
1096
+ if (!target || (target.tool !== 'image' && target.tool !== 'mediaref')) return;
1097
+ const after = before.map((s) => {
1098
+ if (s.id !== id) return s;
1099
+ return target.tool === 'image'
1100
+ ? ({ ...s, href: path } as ImageStroke)
1101
+ : ({ ...s, src: path } as MediaRefStroke);
1102
+ });
1103
+ commitStrokes(before, after, 'replace media');
1060
1104
  }
1061
1105
  };
1062
1106
  window.addEventListener('message', onMessage);
1063
1107
  return () => window.removeEventListener('message', onMessage);
1064
- }, [annotSel, strokes, setVisible]);
1108
+ }, [annotSel, strokes, setVisible, commitStrokes]);
1065
1109
 
1066
1110
  // Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
1067
1111
  // owned by the dev-server menubar (Help button); we no longer ship an
@@ -1167,15 +1211,63 @@ export function AnnotationsLayer() {
1167
1211
  [commitStrokes, annotSel]
1168
1212
  );
1169
1213
 
1214
+ // Media reference (DDR-150 P4): a video/audio file dropped on the canvas BODY
1215
+ // becomes a versioned reference chip (NOT a source insert, NOT a played
1216
+ // element) carrying its assets/ path — the "nahazet klipy → agent z toho udělá
1217
+ // video" artifact. Upload to assets/, then commit a MediaRefStroke; on failure
1218
+ // toast. No poster probe in v1 — a media glyph tile (▶/♪) + filename.
1219
+ const createMediaReference = useCallback(
1220
+ (file: File, mediaKind: 'video' | 'audio', world: [number, number]) => {
1221
+ const w = MEDIAREF_DEFAULT_W;
1222
+ // Video chips are taller — they host the inline 16:9 player (dogfood #8).
1223
+ const h = mediaKind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
1224
+ void uploadAsset(file).then((res) => {
1225
+ if (!('path' in res)) {
1226
+ showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
1227
+ return;
1228
+ }
1229
+ const id = rid();
1230
+ const ref: MediaRefStroke = {
1231
+ id,
1232
+ tool: 'mediaref',
1233
+ x: world[0] - w / 2,
1234
+ y: world[1] - h / 2,
1235
+ w,
1236
+ h,
1237
+ src: res.path,
1238
+ mediaKind,
1239
+ title: (file.name || res.path).slice(0, 300),
1240
+ };
1241
+ const before = strokesRef.current;
1242
+ commitStrokes(before, [...before, ref], `add ${mediaKind} reference`);
1243
+ annotSel?.replace([id]);
1244
+ const sizeMb = file.size / (1024 * 1024);
1245
+ showCanvasToast(
1246
+ `Added ${mediaKind} reference · ${res.path}${sizeMb > 20 ? ' · ⚠ >20 MB rides git + sync' : ''}`
1247
+ );
1248
+ });
1249
+ },
1250
+ [commitStrokes, annotSel]
1251
+ );
1252
+
1170
1253
  const mediaCallbacks = useMemo(
1171
- () => ({ onImage: createImageFromFile, onLink: createLink }),
1172
- [createImageFromFile, createLink]
1254
+ () => ({ onImage: createImageFromFile, onLink: createLink, onMedia: createMediaReference }),
1255
+ [createImageFromFile, createLink, createMediaReference]
1173
1256
  );
1174
1257
  // Media intake is paste/drop only (per product steer — no toolbar buttons):
1175
1258
  // drop an image / URL or Cmd+V a clipboard image / link straight onto the
1176
1259
  // canvas. The hook owns the dragover/drop/paste wiring; the create callbacks
1177
1260
  // hold the commit/undo sink + screenToWorld.
1178
- useCanvasMediaDrop({ enabled: visible, screenToWorld, callbacks: mediaCallbacks });
1261
+ //
1262
+ // DDR-150 dogfood #8 — intake was gated on `visible` (annotations toggled on),
1263
+ // so with annotations hidden (⇧P) a Finder drop silently did NOTHING. Intake
1264
+ // now stays live whenever we're not presenting; the committed stroke simply
1265
+ // shows once annotations are visible again.
1266
+ useCanvasMediaDrop({
1267
+ enabled: !(chrome?.present ?? false),
1268
+ screenToWorld,
1269
+ callbacks: mediaCallbacks,
1270
+ });
1179
1271
 
1180
1272
  const eraseAt = useCallback(
1181
1273
  (wx: number, wy: number) => {
@@ -1636,6 +1728,7 @@ export function AnnotationsLayer() {
1636
1728
  t === 'sticky' ||
1637
1729
  t === 'image' ||
1638
1730
  t === 'link' ||
1731
+ t === 'mediaref' ||
1639
1732
  t === 'section')
1640
1733
  ) {
1641
1734
  return id;
@@ -1644,6 +1737,7 @@ export function AnnotationsLayer() {
1644
1737
  };
1645
1738
 
1646
1739
  const onDown = (e: PointerEvent) => {
1740
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
1647
1741
  if (e.button !== 0) return;
1648
1742
  if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
1649
1743
  const target = e.target as Element | null;
@@ -1937,6 +2031,7 @@ export function AnnotationsLayer() {
1937
2031
  if (typeof document === 'undefined') return;
1938
2032
  if (tool !== 'move') return;
1939
2033
  const onDbl = (e: MouseEvent) => {
2034
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
1940
2035
  const target = e.target as Element | null;
1941
2036
  const node = target?.closest?.('[data-id][data-tool]');
1942
2037
  if (!node) return;
@@ -2460,6 +2555,7 @@ export function AnnotationsLayer() {
2460
2555
  if (typeof document === 'undefined') return;
2461
2556
  if (tool !== 'move') return;
2462
2557
  const onDown = (e: PointerEvent) => {
2558
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2463
2559
  if (e.button !== 0) return;
2464
2560
  const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
2465
2561
  if (!dot) return;
@@ -2575,6 +2671,7 @@ export function AnnotationsLayer() {
2575
2671
  // propagation WITHOUT preventDefault, so the native contextmenu event
2576
2672
  // (which opens OUR menu above) still follows.
2577
2673
  const onDown = (e: PointerEvent) => {
2674
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
2578
2675
  if (e.button !== 2) return;
2579
2676
  if (!strokeAt(e.target as Element | null)) return;
2580
2677
  e.stopImmediatePropagation();
@@ -2665,9 +2762,27 @@ export function AnnotationsLayer() {
2665
2762
  if (members) annotSel.replace(members);
2666
2763
  } else if (action === 'ungroup') {
2667
2764
  store.ungroupSelection(sel);
2765
+ } else if (action === 'replace') {
2766
+ // Stage F3 — "Replace…" on an ImageStroke/MediaRefStroke. The canvas
2767
+ // REQUESTS (untrusted-origin postMessage, DDR-054); the main-origin shell
2768
+ // opens the AssetPicker and posts the picked path back down (handled by
2769
+ // the `replace-annotation-media` listener above, which owns the write —
2770
+ // this model has no data-cd-id, so it can't ride edit-attr like F2).
2771
+ if (sel.length !== 1) return;
2772
+ const target = strokesById.get(sel[0] as string);
2773
+ if (!target || (target.tool !== 'image' && target.tool !== 'mediaref')) return;
2774
+ const before = target.tool === 'image' ? target.href : target.src;
2775
+ try {
2776
+ window.parent.postMessage(
2777
+ { dgn: 'replace-annotation-media-request', id: target.id, before },
2778
+ '*'
2779
+ );
2780
+ } catch {
2781
+ /* detached / cross-origin teardown */
2782
+ }
2668
2783
  }
2669
2784
  },
2670
- [annotSel, copySelection, pasteStrokesText]
2785
+ [annotSel, copySelection, pasteStrokesText, strokesById]
2671
2786
  );
2672
2787
 
2673
2788
  // FigJam v3 — first time a multi-selection lands, surface the group /
@@ -2724,6 +2839,10 @@ export function AnnotationsLayer() {
2724
2839
  onCancelEdit={cancelEditing}
2725
2840
  />
2726
2841
  ) : null}
2842
+ {/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
2843
+ overlay in the world div; see MediaRefPlayers for why not
2844
+ foreignObject). */}
2845
+ <MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
2727
2846
  <AnnotationContextToolbar
2728
2847
  editingId={
2729
2848
  editingTarget?.kind === 'anchored'
@@ -2740,6 +2859,10 @@ export function AnnotationsLayer() {
2740
2859
  pos={ctxMenu}
2741
2860
  selCount={annotSel.selectedIds.length}
2742
2861
  canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
2862
+ canReplace={
2863
+ selectedStrokes.length === 1 &&
2864
+ (selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
2865
+ }
2743
2866
  onAction={onMenuAction}
2744
2867
  onClose={() => setCtxMenu(null)}
2745
2868
  />
@@ -3110,6 +3233,92 @@ function AnnotationsSvg({
3110
3233
  );
3111
3234
  }
3112
3235
 
3236
+ /**
3237
+ * DDR-150 dogfood #8 — the mediaref chips' inline players, rendered as PLAIN
3238
+ * HTML absolutely positioned in the world div (which carries the pan/zoom CSS
3239
+ * transform), NOT as SVG foreignObject: Chromium hit-tests foreignObject
3240
+ * content under a transformed ancestor in the un-transformed coordinate space,
3241
+ * so real clicks miss the player at most zoom levels. HTML children of the
3242
+ * transformed div hit-test correctly. The [data-mediaref-player] attr keeps
3243
+ * every document-capture annotation handler out (isMediaPlayerTarget guard).
3244
+ */
3245
+ function MediaRefPlayers({
3246
+ worldRef,
3247
+ strokes,
3248
+ visible,
3249
+ }: {
3250
+ worldRef: React.RefObject<HTMLElement | null>;
3251
+ strokes: readonly Stroke[];
3252
+ visible: boolean;
3253
+ }) {
3254
+ const target = worldRef.current;
3255
+ if (!target || !visible) return null;
3256
+ const HEADER = 26;
3257
+ const refs = strokes.filter(
3258
+ (s): s is MediaRefStroke => s.tool === 'mediaref' && !!s.src && Math.abs(s.h) > HEADER + 12
3259
+ );
3260
+ if (refs.length === 0) return null;
3261
+ return createPortal(
3262
+ <>
3263
+ {refs.map((s) => {
3264
+ const x = Math.min(s.x, s.x + s.w);
3265
+ const y = Math.min(s.y, s.y + s.h);
3266
+ const w = Math.abs(s.w);
3267
+ const h = Math.abs(s.h);
3268
+ const mediaUrl = resolveAssetHref(s.src);
3269
+ const isAudio = s.mediaKind === 'audio';
3270
+ return (
3271
+ <div
3272
+ key={`mrp-${s.id}`}
3273
+ data-mediaref-player="1"
3274
+ style={{
3275
+ position: 'absolute',
3276
+ left: x + 4,
3277
+ top: y + HEADER,
3278
+ width: Math.max(8, w - 8),
3279
+ height: Math.max(8, h - HEADER - 4),
3280
+ borderRadius: 6,
3281
+ overflow: 'hidden',
3282
+ zIndex: 4,
3283
+ }}
3284
+ >
3285
+ {isAudio ? (
3286
+ // biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — there is no caption source to point a <track> at.
3287
+ <audio
3288
+ controls
3289
+ preload="metadata"
3290
+ src={mediaUrl}
3291
+ style={{ width: '100%', height: '100%' }}
3292
+ />
3293
+ ) : (
3294
+ // biome-ignore lint/a11y/useMediaCaption: user-dropped reference media — no caption source exists for an arbitrary dragged-in clip.
3295
+ <video
3296
+ // Chromium's native controls already toggle play/pause on a
3297
+ // content-area click (a custom click listener would double-fire
3298
+ // against it and cancel itself out — verified live). The router
3299
+ // overlay-skip + the annotation-handler guards are what make
3300
+ // these native interactions reachable.
3301
+ controls
3302
+ preload="metadata"
3303
+ playsInline
3304
+ src={mediaUrl}
3305
+ style={{
3306
+ width: '100%',
3307
+ height: '100%',
3308
+ objectFit: 'contain',
3309
+ background: '#000',
3310
+ display: 'block',
3311
+ }}
3312
+ />
3313
+ )}
3314
+ </div>
3315
+ );
3316
+ })}
3317
+ </>,
3318
+ target
3319
+ );
3320
+ }
3321
+
3113
3322
  function TextEditor({
3114
3323
  anchorId,
3115
3324
  host,
@@ -3165,6 +3374,7 @@ function TextEditor({
3165
3374
  useEffect(() => {
3166
3375
  if (typeof document === 'undefined') return;
3167
3376
  const onDown = (e: PointerEvent) => {
3377
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3168
3378
  const el = ref.current;
3169
3379
  if (!el) return;
3170
3380
  if (el.contains(e.target as Node)) return;
@@ -3427,6 +3637,7 @@ function StandaloneTextEditor({
3427
3637
  useEffect(() => {
3428
3638
  if (typeof document === 'undefined') return;
3429
3639
  const onDown = (e: PointerEvent) => {
3640
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3430
3641
  const el = ref.current;
3431
3642
  if (!el) return;
3432
3643
  if (el.contains(e.target as Node)) return;
@@ -3586,12 +3797,15 @@ function AnnotationContextMenu({
3586
3797
  pos,
3587
3798
  selCount,
3588
3799
  canUngroup,
3800
+ canReplace,
3589
3801
  onAction,
3590
3802
  onClose,
3591
3803
  }: {
3592
3804
  pos: { x: number; y: number };
3593
3805
  selCount: number;
3594
3806
  canUngroup: boolean;
3807
+ /** Stage F3 — exactly one ImageStroke/MediaRefStroke is selected. */
3808
+ canReplace: boolean;
3595
3809
  onAction: (action: string) => void;
3596
3810
  onClose: () => void;
3597
3811
  }) {
@@ -3609,6 +3823,7 @@ function AnnotationContextMenu({
3609
3823
  if (nx !== at.x || ny !== at.y) setAt({ x: nx, y: ny });
3610
3824
  el.querySelector<HTMLButtonElement>('button.dc-menu-item:not([disabled])')?.focus();
3611
3825
  const onDown = (e: PointerEvent) => {
3826
+ if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
3612
3827
  if (!el.contains(e.target as Node)) onClose();
3613
3828
  };
3614
3829
  const onKey = (e: KeyboardEvent) => {
@@ -3657,6 +3872,7 @@ function AnnotationContextMenu({
3657
3872
  {item('cut', 'Cut', '⌘X')}
3658
3873
  {item('paste', 'Paste', '⌘V')}
3659
3874
  {item('duplicate', 'Duplicate', '⌘D')}
3875
+ {canReplace ? item('replace', 'Replace…') : null}
3660
3876
  <div className="dc-menu-sep" aria-hidden="true" />
3661
3877
  {item('front', 'Bring to front', ']')}
3662
3878
  {item('forward', 'Bring forward', '⌘]')}
@@ -4176,6 +4392,91 @@ function StrokeNodeBase({
4176
4392
  </g>
4177
4393
  );
4178
4394
  }
4395
+ if (stroke.tool === 'mediaref') {
4396
+ // DDR-150 P4 + dogfood #8 — reference chip with a REAL inline player.
4397
+ // LIVE-RENDER ONLY: the <foreignObject> + <video>/<audio> below never
4398
+ // persist — the model serializer still writes the sanitizer-safe data-*
4399
+ // card (foreignObject is stripped by sanitizeAnnotationSvg by design).
4400
+ // The 26px header strip (badge + filename) is the select/drag handle; the
4401
+ // player area is fenced off from the annotation handlers by the
4402
+ // [data-mediaref-player] window-capture guard.
4403
+ const x = Math.min(stroke.x, stroke.x + stroke.w);
4404
+ const y = Math.min(stroke.y, stroke.y + stroke.h);
4405
+ const w = Math.abs(stroke.w);
4406
+ const h = Math.abs(stroke.h);
4407
+ const HEADER = 26;
4408
+ const isAudio = stroke.mediaKind === 'audio';
4409
+ const mediaUrl = stroke.src ? resolveAssetHref(stroke.src) : '';
4410
+ const shownTitle = clampLinkTitle(stroke.title, Math.max(8, Math.floor((w - 40) / 7)));
4411
+ const textFont = {
4412
+ fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
4413
+ } as const;
4414
+ return (
4415
+ <g
4416
+ data-id={stroke.id}
4417
+ data-tool="mediaref"
4418
+ data-src={stroke.src}
4419
+ data-media-kind={stroke.mediaKind}
4420
+ data-title={stroke.title}
4421
+ pointerEvents={hitMode}
4422
+ >
4423
+ <rect
4424
+ x={x}
4425
+ y={y}
4426
+ width={w}
4427
+ height={h}
4428
+ rx={8}
4429
+ ry={8}
4430
+ fill={LINK_CARD_FILL}
4431
+ stroke={LINK_CARD_STROKE}
4432
+ strokeWidth={1}
4433
+ vectorEffect="non-scaling-stroke"
4434
+ filter="url(#dc-sticky-shadow)"
4435
+ />
4436
+ <svg
4437
+ x={x + 8}
4438
+ y={y + 5}
4439
+ width={16}
4440
+ height={16}
4441
+ viewBox="0 0 24 24"
4442
+ fill={LINK_GLYPH_STROKE}
4443
+ stroke="none"
4444
+ aria-hidden="true"
4445
+ >
4446
+ <path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
4447
+ </svg>
4448
+ <text
4449
+ x={x + 30}
4450
+ y={y + 9}
4451
+ fontSize={11}
4452
+ fill={LINK_TITLE_FILL}
4453
+ fontWeight={600}
4454
+ dominantBaseline="hanging"
4455
+ style={textFont}
4456
+ >
4457
+ {shownTitle}
4458
+ </text>
4459
+ {/* The inline player itself is an HTML overlay portaled beside this SVG
4460
+ (MediaRefPlayers below) — NOT a foreignObject: Chromium hit-tests
4461
+ foreignObject content under a CSS-transformed ancestor in the WRONG
4462
+ coordinate space (the un-panned/un-zoomed one), so real clicks miss
4463
+ the player at most zoom levels while elementFromPoint lies that
4464
+ they'd land. Plain HTML in the transformed world hit-tests right. */}
4465
+ {!mediaUrl ? (
4466
+ <text
4467
+ x={x + 30}
4468
+ y={y + HEADER + 10}
4469
+ fontSize={10}
4470
+ fill={LINK_DOMAIN_FILL}
4471
+ dominantBaseline="hanging"
4472
+ style={textFont}
4473
+ >
4474
+ (missing media reference)
4475
+ </text>
4476
+ ) : null}
4477
+ </g>
4478
+ );
4479
+ }
4179
4480
  if (stroke.tool === 'section') {
4180
4481
  const x = Math.min(stroke.x, stroke.x + stroke.w);
4181
4482
  const y = Math.min(stroke.y, stroke.y + stroke.h);