@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
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.
@@ -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);
@@ -266,6 +266,30 @@ export interface LinkStroke extends StrokeBase {
266
266
  title: string;
267
267
  domain: string;
268
268
  }
269
+ /**
270
+ * DDR-150 P4 — a video/audio file dropped on the canvas BODY (not the timeline)
271
+ * as a reference chip. It's the "nahazet klipy → agent z toho udělá video"
272
+ * artifact: a non-destructive, versioned pointer to an `assets/…` clip the agent
273
+ * can enumerate off the saved `.annotations.svg` (via `data-src`), then assemble
274
+ * into a comp. Distinct from a timeline drop (which INSERTS a `<Sequence>`) and
275
+ * from an ImageStroke (a rendered picture). Renders as a card: a media glyph
276
+ * (▶ video / ♪ audio) + the filename. `src` is ALWAYS a relative `assets/<sha8>`
277
+ * path (never seeked by the capture spine — it's a reference, excluded from
278
+ * export by `?hide-chrome`). Persists as an allowlisted `<g>` like LinkStroke —
279
+ * `data-src`/`data-media-kind`/`data-title` are the round-trip source of truth.
280
+ */
281
+ export interface MediaRefStroke extends StrokeBase {
282
+ tool: 'mediaref';
283
+ x: number;
284
+ y: number;
285
+ w: number;
286
+ h: number;
287
+ /** Relative `assets/<sha8>.<ext>` path — the agent reads this off the SVG. */
288
+ src: string;
289
+ mediaKind: 'video' | 'audio';
290
+ /** Human-facing label (the dropped file's name). */
291
+ title: string;
292
+ }
269
293
  /**
270
294
  * FigJam v3 — section: a labelled organizing container. Renders as a soft
271
295
  * rounded region with a name chip above the top-left corner; its INTERIOR is
@@ -294,6 +318,7 @@ export type Stroke =
294
318
  | StickyStroke
295
319
  | ImageStroke
296
320
  | LinkStroke
321
+ | MediaRefStroke
297
322
  | SectionStroke;
298
323
 
299
324
  /**
@@ -528,6 +553,11 @@ export const IMAGE_MIN_SIZE = 16;
528
553
  export const IMAGE_MAX_DROP_SIDE = 480;
529
554
  export const LINK_DEFAULT_W = 260;
530
555
  export const LINK_DEFAULT_H = 76;
556
+ // DDR-150 P4 — media-reference chip (dropped video/audio → assets/ pointer).
557
+ export const MEDIAREF_DEFAULT_W = 280;
558
+ export const MEDIAREF_DEFAULT_H = 76;
559
+ /** Video chips are taller: 26px header + a 16:9 inline player area (dogfood #8). */
560
+ export const MEDIAREF_VIDEO_H = 190;
531
561
  export const LINK_CARD_FILL = '#ffffff';
532
562
  export const LINK_CARD_STROKE = '#d4d4d8';
533
563
  export const LINK_DOMAIN_FILL = '#71717a';
@@ -539,6 +569,12 @@ export const LINK_GLYPH_STROKE = '#52525b';
539
569
  // <rect> geometry), so render/serialize only need to agree visually.
540
570
  export const LINK_GLYPH_D1 = 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71';
541
571
  export const LINK_GLYPH_D2 = 'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71';
572
+ // DDR-150 P4 — media-reference chip glyphs (24×24 viewBox, filled). One source
573
+ // for serialize + StrokeNode render, like the link glyph. Video = play triangle;
574
+ // audio = a filled note. The parser ignores the glyph (reads data-* + geometry).
575
+ export const MEDIAREF_VIDEO_GLYPH = 'M8 5v14l11-7z';
576
+ export const MEDIAREF_AUDIO_GLYPH =
577
+ 'M9 18V6l10-2v11.5a2.5 2.5 0 1 1-2-2.45V7.3L11 8.6v6.9a2.5 2.5 0 1 1-2-2.45z';
542
578
 
543
579
  /** Card text positions, derived purely from the bbox (idempotent round-trip). */
544
580
  export function linkCardLayout(x: number, y: number, w: number, h: number) {
@@ -1038,6 +1074,37 @@ function strokeToSvgElBase(s: Stroke): string {
1038
1074
  `</g>`
1039
1075
  );
1040
1076
  }
1077
+ if (s.tool === 'mediaref') {
1078
+ // DDR-150 P4 — reference chip for a dropped clip. Like the link card: the
1079
+ // data-* are the round-trip source of truth (the agent reads data-src to
1080
+ // enumerate refs); the inner rect/glyph/text are the inert, sanitizer-safe
1081
+ // visual. NEVER a <Video>/<Audio> element (that would try to play/seek) —
1082
+ // it's a pointer, drawn as a still card.
1083
+ const nx = Math.min(s.x, s.x + s.w);
1084
+ const ny = Math.min(s.y, s.y + s.h);
1085
+ const nw = Math.abs(s.w);
1086
+ const nh = Math.abs(s.h);
1087
+ const lay = linkCardLayout(nx, ny, nw, nh);
1088
+ const shownTitle = clampLinkTitle(s.title, lay.textMaxChars);
1089
+ const glyph =
1090
+ s.mediaKind === 'audio'
1091
+ ? `<path d="${MEDIAREF_AUDIO_GLYPH}"/>`
1092
+ : `<path d="${MEDIAREF_VIDEO_GLYPH}"/>`;
1093
+ return (
1094
+ `<g data-id="${esc(s.id)}" data-tool="mediaref" data-src="${escAttr(s.src)}" data-media-kind="${escAttr(
1095
+ s.mediaKind
1096
+ )}" data-title="${escAttr(s.title)}">` +
1097
+ `<rect x="${nx}" y="${ny}" width="${nw}" height="${nh}" rx="8" ry="8" fill="${LINK_CARD_FILL}" stroke="${LINK_CARD_STROKE}" stroke-width="1"/>` +
1098
+ `<svg x="${lay.glyph.x}" y="${lay.glyph.y}" width="${lay.glyph.size}" height="${lay.glyph.size}" viewBox="0 0 24 24" fill="${LINK_GLYPH_STROKE}" stroke="none">${glyph}</svg>` +
1099
+ `<text x="${lay.textX}" y="${lay.domain.y}" font-size="${lay.domain.fontSize}" fill="${LINK_DOMAIN_FILL}" dominant-baseline="hanging">${esc(
1100
+ `${s.mediaKind} · reference`
1101
+ )}</text>` +
1102
+ `<text x="${lay.textX}" y="${lay.title.y}" font-size="${lay.title.fontSize}" fill="${LINK_TITLE_FILL}" font-weight="600" dominant-baseline="hanging">${esc(
1103
+ shownTitle
1104
+ )}</text>` +
1105
+ `</g>`
1106
+ );
1107
+ }
1041
1108
  if (s.tool === 'section') {
1042
1109
  // FigJam v3 — inert persisted form: region rect + chip label text. Colors
1043
1110
  // are render-time chrome; the parser reads geometry off the rect and the
@@ -1463,6 +1530,22 @@ export function svgToStrokes(svgText: string): Stroke[] {
1463
1530
  push({ id, tool: 'link', x, y, w, h, url, title, domain });
1464
1531
  continue;
1465
1532
  }
1533
+ if (tool === 'mediaref') {
1534
+ // DDR-150 P4 — geometry off the <rect> child (mirrors link/sticky);
1535
+ // data-src is the pointer the agent enumerates. A src that fails the
1536
+ // assets/ shape is dropped to '' (an inert chip that references nothing).
1537
+ const rectEl = el.querySelector('rect');
1538
+ const x = pfloat(rectEl?.getAttribute('x'));
1539
+ const y = pfloat(rectEl?.getAttribute('y'));
1540
+ const w = pfloat(rectEl?.getAttribute('width'), MEDIAREF_DEFAULT_W);
1541
+ const h = pfloat(rectEl?.getAttribute('height'), MEDIAREF_DEFAULT_H);
1542
+ const rawSrc = el.getAttribute('data-src') || '';
1543
+ const src = ASSET_MEDIA_SRC_RE.test(rawSrc) ? rawSrc : '';
1544
+ const mediaKind = el.getAttribute('data-media-kind') === 'audio' ? 'audio' : 'video';
1545
+ const title = el.getAttribute('data-title') || src;
1546
+ push({ id, tool: 'mediaref', x, y, w, h, src, mediaKind, title });
1547
+ continue;
1548
+ }
1466
1549
  if (tool === 'text') {
1467
1550
  const rawAnchor = el.getAttribute('data-anchor-id');
1468
1551
  const fontSize =
@@ -1650,8 +1733,8 @@ export function strokeHitTest(s: Stroke, wx: number, wy: number, tol: number): b
1650
1733
  return onEdge;
1651
1734
  }
1652
1735
  const t = Math.max(tol, 'width' in s ? s.width : 2);
1653
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link') {
1654
- // Sticky / image / link are solid cards — filled-rect hit anywhere inside.
1736
+ if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'mediaref') {
1737
+ // Sticky / image / link / mediaref are solid cards — filled-rect hit inside.
1655
1738
  const xMin = Math.min(s.x, s.x + s.w);
1656
1739
  const xMax = Math.max(s.x, s.x + s.w);
1657
1740
  const yMin = Math.min(s.y, s.y + s.h);
@@ -1844,6 +1927,8 @@ export function isStrokeMeaningful(s: Stroke): boolean {
1844
1927
  // Phase 23 — an image needs real extent; a link needs a non-empty URL.
1845
1928
  if (s.tool === 'image') return Math.abs(s.w) >= IMAGE_MIN_SIZE && Math.abs(s.h) >= IMAGE_MIN_SIZE;
1846
1929
  if (s.tool === 'link') return s.url.trim().length > 0;
1930
+ // A media-reference chip needs a non-empty assets/ src (its whole purpose).
1931
+ if (s.tool === 'mediaref') return s.src.trim().length > 0;
1847
1932
  if (s.tool === 'section')
1848
1933
  return Math.abs(s.w) >= SECTION_MIN_SIZE && Math.abs(s.h) >= SECTION_MIN_SIZE;
1849
1934
  return Math.hypot(s.x2 - s.x1, s.y2 - s.y1) >= 4;
@@ -1886,9 +1971,15 @@ export function strokeBBox(
1886
1971
  h: Math.abs(s.y2 - s.y1),
1887
1972
  };
1888
1973
  }
1889
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'section') {
1890
- // Phase 23 — image + link are rect-shaped media, same bbox as a sticky
1891
- // card; FigJam v3 sections share it too.
1974
+ if (
1975
+ s.tool === 'sticky' ||
1976
+ s.tool === 'image' ||
1977
+ s.tool === 'link' ||
1978
+ s.tool === 'mediaref' ||
1979
+ s.tool === 'section'
1980
+ ) {
1981
+ // Phase 23 — image + link + mediaref are rect-shaped cards, same bbox as a
1982
+ // sticky; FigJam v3 sections share it too.
1892
1983
  return {
1893
1984
  x: Math.min(s.x, s.x + s.w),
1894
1985
  y: Math.min(s.y, s.y + s.h),
@@ -1928,7 +2019,13 @@ export function translateOne(s: Stroke, dx: number, dy: number): Stroke {
1928
2019
  if (s.tool === 'ellipse') return { ...s, cx: s.cx + dx, cy: s.cy + dy };
1929
2020
  if (s.tool === 'arrow')
1930
2021
  return { ...s, x1: s.x1 + dx, y1: s.y1 + dy, x2: s.x2 + dx, y2: s.y2 + dy };
1931
- if (s.tool === 'sticky' || s.tool === 'image' || s.tool === 'link' || s.tool === 'section')
2022
+ if (
2023
+ s.tool === 'sticky' ||
2024
+ s.tool === 'image' ||
2025
+ s.tool === 'link' ||
2026
+ s.tool === 'mediaref' ||
2027
+ s.tool === 'section'
2028
+ )
1932
2029
  return { ...s, x: s.x + dx, y: s.y + dy };
1933
2030
  // text — anchored inherits its host's bbox (moves with the host); standalone
1934
2031
  // (Phase 21) carries its own world (x, y) and translates directly.
@@ -1997,6 +2094,10 @@ export const ANNOTATION_SVG_ELEMENTS = new Set([
1997
2094
  * is stripped by Rule 3. Pairs with the asset-write caps (DDR Task 9).
1998
2095
  */
1999
2096
  export const ASSET_IMAGE_HREF_RE = /^assets\/[A-Za-z0-9._-]+\.(?:png|jpe?g|webp|gif)$/;
2097
+ // DDR-150 P4 — a media-reference chip's data-src: a relative assets/ path to a
2098
+ // video/audio clip (no scheme, no traversal). Same shape as the image href gate.
2099
+ export const ASSET_MEDIA_SRC_RE =
2100
+ /^assets\/[A-Za-z0-9._-]+\.(?:mp4|webm|mov|m4v|mp3|wav|m4a|aac|ogg)$/;
2000
2101
 
2001
2102
  /**
2002
2103
  * A3 (DDR-060 F1 re-audit) — sanitize an annotation SVG before it is persisted /