@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
@@ -0,0 +1,168 @@
1
+ // video-comp — DDR-148 seek-bridge unit coverage.
2
+ //
3
+ // The Remotion Player's actual frame render needs a real browser (rAF, layout,
4
+ // media) — that determinism ("two seeks to frame N → identical pixels") is an
5
+ // agent-browser live check (Task 11). What IS pure + testable here is the seek
6
+ // BRIDGE the capture spine + Timeline panel drive: the comp registry, the
7
+ // clamped seek routing to each Player, and the serializable snapshot. We stand
8
+ // up happy-dom, install the bridge without mounting a Player, register a fake
9
+ // Player handle (a seekTo/pause spy), and assert the routing contract.
10
+
11
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
12
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
13
+
14
+ import { installMaudeSeekBridge } from '../video-comp.tsx';
15
+
16
+ interface FakePlayer {
17
+ seekTo: (frame: number) => void;
18
+ pause: () => void;
19
+ seeks: number[];
20
+ pauses: number;
21
+ }
22
+
23
+ interface SeekWindow {
24
+ __maudeVideoComps?: Map<
25
+ string,
26
+ {
27
+ id: string;
28
+ fps: number;
29
+ durationInFrames: number;
30
+ width: number;
31
+ height: number;
32
+ ref: { current: FakePlayer | null };
33
+ }
34
+ >;
35
+ __maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
36
+ __maude_comps__?: () => Array<{
37
+ id: string;
38
+ fps: number;
39
+ durationInFrames: number;
40
+ width: number;
41
+ height: number;
42
+ }>;
43
+ }
44
+
45
+ function makeFakePlayer(): FakePlayer {
46
+ const p: FakePlayer = {
47
+ seeks: [],
48
+ pauses: 0,
49
+ seekTo(frame) {
50
+ p.seeks.push(frame);
51
+ },
52
+ pause() {
53
+ p.pauses += 1;
54
+ },
55
+ };
56
+ return p;
57
+ }
58
+
59
+ function registerComp(
60
+ id: string,
61
+ meta: { fps: number; durationInFrames: number; width: number; height: number },
62
+ player: FakePlayer
63
+ ): void {
64
+ const w = window as unknown as SeekWindow;
65
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
66
+ w.__maudeVideoComps.set(id, { id, ...meta, ref: { current: player } });
67
+ }
68
+
69
+ beforeAll(() => {
70
+ GlobalRegistrator.register();
71
+ });
72
+
73
+ afterAll(async () => {
74
+ await GlobalRegistrator.unregister();
75
+ });
76
+
77
+ beforeEach(() => {
78
+ const w = window as unknown as SeekWindow;
79
+ w.__maudeVideoComps = undefined;
80
+ w.__maude_seek__ = undefined;
81
+ w.__maude_comps__ = undefined;
82
+ });
83
+
84
+ describe('video-comp seek bridge', () => {
85
+ test('installs window.__maude_seek__ + __maude_comps__ idempotently', () => {
86
+ const w = window as unknown as SeekWindow;
87
+ installMaudeSeekBridge();
88
+ const seek1 = w.__maude_seek__;
89
+ const comps1 = w.__maude_comps__;
90
+ expect(typeof seek1).toBe('function');
91
+ expect(typeof comps1).toBe('function');
92
+ installMaudeSeekBridge();
93
+ // Second install must NOT replace the functions (idempotent).
94
+ expect(w.__maude_seek__).toBe(seek1);
95
+ expect(w.__maude_comps__).toBe(comps1);
96
+ });
97
+
98
+ test('__maude_comps__ reflects the live registry (serializable, no functions)', () => {
99
+ const w = window as unknown as SeekWindow;
100
+ installMaudeSeekBridge();
101
+ registerComp(
102
+ 'intro',
103
+ { fps: 30, durationInFrames: 90, width: 640, height: 360 },
104
+ makeFakePlayer()
105
+ );
106
+ const comps = w.__maude_comps__?.() ?? [];
107
+ expect(comps).toHaveLength(1);
108
+ expect(comps[0]).toEqual({
109
+ id: 'intro',
110
+ fps: 30,
111
+ durationInFrames: 90,
112
+ width: 640,
113
+ height: 360,
114
+ });
115
+ // No ref/functions leak into the snapshot.
116
+ expect(Object.keys(comps[0])).not.toContain('ref');
117
+ });
118
+
119
+ test('seek routes to every registered Player, paused first', async () => {
120
+ const w = window as unknown as SeekWindow;
121
+ installMaudeSeekBridge();
122
+ const a = makeFakePlayer();
123
+ const b = makeFakePlayer();
124
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
125
+ registerComp('b', { fps: 24, durationInFrames: 48, width: 320, height: 240 }, b);
126
+ await w.__maude_seek__?.(30);
127
+ expect(a.seeks).toEqual([30]);
128
+ expect(b.seeks).toEqual([30]);
129
+ expect(a.pauses).toBe(1);
130
+ expect(b.pauses).toBe(1);
131
+ });
132
+
133
+ test('seek clamps into [0, durationInFrames-1] per comp + rounds', async () => {
134
+ const w = window as unknown as SeekWindow;
135
+ installMaudeSeekBridge();
136
+ const a = makeFakePlayer();
137
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
138
+ await w.__maude_seek__?.(999); // past the end → clamp to 89
139
+ await w.__maude_seek__?.(-5); // before the start → clamp to 0
140
+ await w.__maude_seek__?.(12.6); // fractional → round to 13
141
+ expect(a.seeks).toEqual([89, 0, 13]);
142
+ });
143
+
144
+ test('two seeks to the same frame issue identical Player calls (determinism contract)', async () => {
145
+ const w = window as unknown as SeekWindow;
146
+ installMaudeSeekBridge();
147
+ const a = makeFakePlayer();
148
+ registerComp('a', { fps: 30, durationInFrames: 90, width: 640, height: 360 }, a);
149
+ await w.__maude_seek__?.(42);
150
+ await w.__maude_seek__?.(42);
151
+ expect(a.seeks).toEqual([42, 42]);
152
+ });
153
+
154
+ test('a Player ref that is not yet ready (null) does not throw', async () => {
155
+ const w = window as unknown as SeekWindow;
156
+ installMaudeSeekBridge();
157
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
158
+ w.__maudeVideoComps.set('pending', {
159
+ id: 'pending',
160
+ fps: 30,
161
+ durationInFrames: 90,
162
+ width: 640,
163
+ height: 360,
164
+ ref: { current: null },
165
+ });
166
+ await expect(w.__maude_seek__?.(10)).resolves.toBeUndefined();
167
+ });
168
+ });
@@ -0,0 +1,149 @@
1
+ // video-render-bridge — DDR-148 addendum (audio export) unit coverage.
2
+ //
3
+ // The actual renderMediaOnWeb call needs a real browser (WebCodecs, canvas
4
+ // decode) — that's a manual/agent-browser verification (see the DDR-148
5
+ // addendum § Manual verification recipe), not a unit test, mirroring
6
+ // video-comp.test.ts's own split between the pure seek-bridge routing (tested
7
+ // here) and live Player rendering (not). What IS pure + testable: `findCompIdIn`
8
+ // (DOM containment resolution) and the `__maude_render_video__` bridge's
9
+ // routing contract — it must merge the registered comp's component/meta with
10
+ // the caller's opts and delegate to the injected `__maudeRenderVideo__`, and
11
+ // fail clearly when either the comp or the render-lib injection is missing.
12
+
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
14
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
15
+
16
+ import { findCompIdIn, installMaudeSeekBridge } from '../video-comp.tsx';
17
+
18
+ interface RenderCall {
19
+ component: unknown;
20
+ inputProps: unknown;
21
+ width: number;
22
+ height: number;
23
+ fps: number;
24
+ durationInFrames: number;
25
+ [k: string]: unknown;
26
+ }
27
+
28
+ interface RenderWindow {
29
+ __maudeVideoComps?: Map<
30
+ string,
31
+ {
32
+ id: string;
33
+ fps: number;
34
+ durationInFrames: number;
35
+ width: number;
36
+ height: number;
37
+ ref: { current: null };
38
+ component: unknown;
39
+ inputProps: Record<string, unknown>;
40
+ }
41
+ >;
42
+ __maude_render_video__?: (compId: string, opts: Record<string, unknown>) => Promise<unknown>;
43
+ __maudeRenderVideo__?: (opts: RenderCall) => Promise<unknown>;
44
+ }
45
+
46
+ function registerComp(
47
+ id: string,
48
+ meta: { fps: number; durationInFrames: number; width: number; height: number },
49
+ component: unknown,
50
+ inputProps: Record<string, unknown> = {}
51
+ ): void {
52
+ const w = window as unknown as RenderWindow;
53
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
54
+ w.__maudeVideoComps.set(id, { id, ...meta, ref: { current: null }, component, inputProps });
55
+ }
56
+
57
+ beforeAll(() => {
58
+ GlobalRegistrator.register();
59
+ });
60
+
61
+ afterAll(async () => {
62
+ await GlobalRegistrator.unregister();
63
+ });
64
+
65
+ beforeEach(() => {
66
+ const w = window as unknown as RenderWindow;
67
+ w.__maudeVideoComps = undefined;
68
+ w.__maudeRenderVideo__ = undefined;
69
+ (w as unknown as { __maude_render_video__?: unknown }).__maude_render_video__ = undefined;
70
+ (w as unknown as { __maude_seek__?: unknown }).__maude_seek__ = undefined;
71
+ (w as unknown as { __maude_comps__?: unknown }).__maude_comps__ = undefined;
72
+ });
73
+
74
+ describe('findCompIdIn', () => {
75
+ test('resolves the nearest [data-comp-id] descendant', () => {
76
+ document.body.innerHTML =
77
+ '<div data-dc-screen="reel"><div data-comp-id="videocomp-2"><span>x</span></div></div>';
78
+ const artboard = document.querySelector('[data-dc-screen="reel"]');
79
+ expect(artboard).not.toBeNull();
80
+ expect(findCompIdIn(artboard as Element)).toBe('videocomp-2');
81
+ });
82
+
83
+ test('returns null for an artboard with no registered comp (ordinary/CSS artboard)', () => {
84
+ document.body.innerHTML = '<div data-dc-screen="static"><p>just a div</p></div>';
85
+ const artboard = document.querySelector('[data-dc-screen="static"]');
86
+ expect(findCompIdIn(artboard as Element)).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe('__maude_render_video__ bridge', () => {
91
+ test('merges the registered component + meta with caller opts, delegates to __maudeRenderVideo__', async () => {
92
+ const w = window as unknown as RenderWindow;
93
+ installMaudeSeekBridge();
94
+ const FakeComp = () => null;
95
+ registerComp('reel', { fps: 30, durationInFrames: 228, width: 960, height: 540 }, FakeComp, {
96
+ title: 'Showreel',
97
+ });
98
+ let received: RenderCall | null = null;
99
+ w.__maudeRenderVideo__ = async (opts) => {
100
+ received = opts;
101
+ return { b64: 'AA==', bytes: 2, container: 'mp4', videoCodec: 'h264', audioCodec: 'aac' };
102
+ };
103
+ const result = await w.__maude_render_video__?.('reel', {
104
+ container: 'mp4',
105
+ scale: 2,
106
+ muted: false,
107
+ frameRange: [0, 30],
108
+ });
109
+ expect(result).toEqual({
110
+ b64: 'AA==',
111
+ bytes: 2,
112
+ container: 'mp4',
113
+ videoCodec: 'h264',
114
+ audioCodec: 'aac',
115
+ });
116
+ expect(received).not.toBeNull();
117
+ const call = received as unknown as RenderCall;
118
+ expect(call.component).toBe(FakeComp);
119
+ expect(call.inputProps).toEqual({ title: 'Showreel' });
120
+ expect(call.width).toBe(960);
121
+ expect(call.height).toBe(540);
122
+ expect(call.fps).toBe(30);
123
+ expect(call.durationInFrames).toBe(228);
124
+ // Caller opts pass through alongside the merged meta.
125
+ expect(call.container).toBe('mp4');
126
+ expect(call.scale).toBe(2);
127
+ expect(call.muted).toBe(false);
128
+ expect(call.frameRange).toEqual([0, 30]);
129
+ });
130
+
131
+ test('throws a clear error for an unregistered compId', async () => {
132
+ const w = window as unknown as RenderWindow;
133
+ installMaudeSeekBridge();
134
+ await expect(w.__maude_render_video__?.('nope', { container: 'mp4' })).rejects.toThrow(
135
+ /no registered video-comp/i
136
+ );
137
+ });
138
+
139
+ test('throws a clear error when the render-lib bundle was never injected', async () => {
140
+ const w = window as unknown as RenderWindow;
141
+ installMaudeSeekBridge();
142
+ registerComp('reel', { fps: 30, durationInFrames: 90, width: 960, height: 540 }, () => null);
143
+ // __maudeRenderVideo__ intentionally left unset (simulates a capture that
144
+ // skipped addScriptTag — e.g. gif/ordinary-artboard export never injects it).
145
+ await expect(w.__maude_render_video__?.('reel', { container: 'mp4' })).rejects.toThrow(
146
+ /render-lib bundle not injected/i
147
+ );
148
+ });
149
+ });
@@ -16,7 +16,9 @@ import { useEffect, useRef, useState } from 'react';
16
16
 
17
17
  import {
18
18
  IconChevronDown,
19
+ IconLetterA,
19
20
  IconPresentation,
21
+ IconSquare,
20
22
  SHAPE_KIND_ICONS,
21
23
  TOOL_ICONS,
22
24
  } from './canvas-icons.tsx';
@@ -214,6 +216,35 @@ const PALETTE_CSS = `
214
216
  background: color-mix(in oklab, var(--maude-hud-accent, #d63b1f) 14%, transparent);
215
217
  color: var(--maude-hud-accent, #d63b1f);
216
218
  }
219
+ /* Stage I3 tail — "+ Element" insert popover. A vertical labeled list (not the
220
+ shape popover's icon grid): Div/Text/Image have no recognizable glyph on
221
+ their own, and this is a rare, must-be-legible action, not a recalled tool. */
222
+ .dc-tool-palette .dc-tp-insert { position: relative; display: inline-flex; }
223
+ .dc-tp-insert-popover {
224
+ position: absolute;
225
+ bottom: calc(100% + 8px);
226
+ left: 50%;
227
+ transform: translateX(-50%);
228
+ display: flex;
229
+ flex-direction: column;
230
+ gap: 2px;
231
+ padding: 4px;
232
+ min-width: 128px;
233
+ background: var(--maude-chrome-bg-0, #ffffff);
234
+ border: 1px solid var(--maude-chrome-fg-0, #1c1917);
235
+ border-radius: 8px;
236
+ box-shadow: 0 6px 24px var(--maude-chrome-shadow, color-mix(in oklab, #1c1917 10%, transparent));
237
+ z-index: 8;
238
+ }
239
+ .dc-tp-insert-popover button {
240
+ width: auto;
241
+ height: auto;
242
+ justify-content: flex-start;
243
+ gap: 8px;
244
+ padding: 6px 8px;
245
+ border-radius: 4px;
246
+ }
247
+ .dc-tp-insert-popover button span.dc-tp-insert-label { font-size: 12px; }
217
248
  `.trim();
218
249
 
219
250
  function ensurePaletteStyles(): void {
@@ -250,6 +281,34 @@ const SHAPE_KINDS: ReadonlyArray<{ kind: ShapeKind; label: string }> = [
250
281
  { kind: 'triangle-down', label: 'Triangle down' },
251
282
  ];
252
283
 
284
+ /**
285
+ * Stage I3 tail — resolve the insert anchor for the tool-palette "+ Element"
286
+ * affordance's "or appends to the active artboard" mode (the plan's fallback
287
+ * when the user hasn't right-clicked a specific element). Growth-only: an
288
+ * artboard's `<article data-dc-screen>` carries NO `data-cd-id` of its own
289
+ * (`DCArtboard` doesn't forward it to the DOM — only its SOURCE JSX tag is
290
+ * stamped, unresolvable from here), so `applyInsertElement`'s `refId` can't
291
+ * target the artboard directly. Anchor on the artboard's LAST direct child
292
+ * that DOES carry a `data-cd-id` and insert `position:'after'` it — the exact
293
+ * relation the context-menu's element-relative insert already uses. Returns
294
+ * `null` for an empty (or artboard-less) canvas — a documented v1 limit; the
295
+ * per-element context-menu "Insert ▸ …" still works there once a first
296
+ * element exists. `doc` is injectable for testing without a real iframe.
297
+ */
298
+ export function resolveInsertAnchor(
299
+ doc: Document = document
300
+ ): { refId: string; position: 'after' } | null {
301
+ const current = doc.querySelector('[data-dc-screen][aria-current="true"]');
302
+ const artboardEl = current ?? doc.querySelector('[data-dc-screen]');
303
+ if (!artboardEl) return null;
304
+ const body = artboardEl.querySelector(':scope > .dc-artboard-body');
305
+ if (!body) return null;
306
+ const kids = Array.from(body.children).reverse();
307
+ const last = kids.find((el) => el.hasAttribute('data-cd-id'));
308
+ const refId = last?.getAttribute('data-cd-id');
309
+ return refId ? { refId, position: 'after' } : null;
310
+ }
311
+
253
312
  // ─────────────────────────────────────────────────────────────────────────────
254
313
  // Component
255
314
 
@@ -259,19 +318,22 @@ export function ToolPalette() {
259
318
  const chrome = useChromeVisibility();
260
319
  const [mounted, setMounted] = useState(false);
261
320
  const [shapeOpen, setShapeOpen] = useState(false);
321
+ const [insertOpen, setInsertOpen] = useState(false);
262
322
  const containerRef = useRef<HTMLDivElement | null>(null);
263
323
 
264
324
  useEffect(() => setMounted(true), []);
265
325
  useEffect(() => {
266
- if (!shapeOpen) return;
326
+ if (!shapeOpen && !insertOpen) return;
267
327
  const onDown = (e: PointerEvent) => {
268
328
  if (!containerRef.current?.contains(e.target as Node)) {
269
329
  setShapeOpen(false);
330
+ setInsertOpen(false);
270
331
  }
271
332
  };
272
333
  const onKey = (e: KeyboardEvent) => {
273
334
  if (e.key === 'Escape') {
274
335
  setShapeOpen(false);
336
+ setInsertOpen(false);
275
337
  }
276
338
  };
277
339
  document.addEventListener('pointerdown', onDown, true);
@@ -280,12 +342,38 @@ export function ToolPalette() {
280
342
  document.removeEventListener('pointerdown', onDown, true);
281
343
  document.removeEventListener('keydown', onKey, true);
282
344
  };
283
- }, [shapeOpen]);
345
+ }, [shapeOpen, insertOpen]);
284
346
  // Close the shape popover whenever the tool changes away from Shape.
285
347
  useEffect(() => {
286
348
  if (tool !== 'shape') setShapeOpen(false);
287
349
  }, [tool]);
288
350
 
351
+ // Stage I3 tail — insert a Div/Text/Image relative to the active artboard's
352
+ // last existing element (`resolveInsertAnchor`). Same request verbs the
353
+ // context-menu's per-element "Insert ▸ …" already posts (`insert-request` /
354
+ // `insert-image-request`, main-origin shell WRITES — DDR-054); a null anchor
355
+ // (empty/artboard-less canvas) is a silent no-op — a documented v1 limit.
356
+ const insertViaPalette = (kind: 'div' | 'text' | 'image') => {
357
+ setInsertOpen(false);
358
+ const anchor = resolveInsertAnchor();
359
+ if (!anchor) return;
360
+ try {
361
+ if (kind === 'image') {
362
+ window.parent.postMessage(
363
+ { dgn: 'insert-image-request', refId: anchor.refId, position: anchor.position },
364
+ '*'
365
+ );
366
+ } else {
367
+ window.parent.postMessage(
368
+ { dgn: 'insert-request', refId: anchor.refId, position: anchor.position, kind },
369
+ '*'
370
+ );
371
+ }
372
+ } catch {
373
+ /* detached / cross-origin teardown */
374
+ }
375
+ };
376
+
289
377
  if (!mounted) return null;
290
378
  // Presentation Mode hides the whole tool palette (clean artboards-only view).
291
379
  if (chrome?.present) return null;
@@ -393,6 +481,38 @@ export function ToolPalette() {
393
481
  </div>
394
482
  <div className="dc-tp-sep" />
395
483
  <div className="dc-tp-group">
484
+ <span className="dc-tp-insert">
485
+ <button
486
+ type="button"
487
+ aria-label="Insert element — Div, Text, or Image"
488
+ aria-haspopup="menu"
489
+ aria-expanded={insertOpen}
490
+ title="Insert element (appends to the active artboard)"
491
+ onClick={() => setInsertOpen((o) => !o)}
492
+ >
493
+ <span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
494
+ +
495
+ </span>
496
+ </button>
497
+ {insertOpen ? (
498
+ <div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
499
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
500
+ <IconSquare />
501
+ <span className="dc-tp-insert-label">Div</span>
502
+ </button>
503
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
504
+ <IconLetterA />
505
+ <span className="dc-tp-insert-label">Text</span>
506
+ </button>
507
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
508
+ <span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
509
+
510
+ </span>
511
+ <span className="dc-tp-insert-label">Image</span>
512
+ </button>
513
+ </div>
514
+ ) : null}
515
+ </span>
396
516
  <button
397
517
  type="button"
398
518
  aria-label="Export (⌘E)"
@@ -32,12 +32,12 @@ const HUD_CSS = `
32
32
  user-select: none;
33
33
  opacity: 0;
34
34
  transform: translateY(-4px);
35
- transition: opacity var(--dur-base, 200ms) linear, transform var(--dur-base, 200ms) ease-out;
35
+ transition: opacity 200ms linear, transform 200ms ease-out;
36
36
  }
37
37
  .dc-undo-hud[data-visible="true"] {
38
38
  opacity: 1;
39
39
  transform: translateY(0);
40
- transition-duration: var(--dur-fast, 120ms);
40
+ transition-duration: 120ms;
41
41
  }
42
42
  @media (prefers-reduced-motion: reduce) {
43
43
  .dc-undo-hud {
@@ -159,6 +159,7 @@ function isResizable(
159
159
  s.tool === 'sticky' ||
160
160
  s.tool === 'image' ||
161
161
  s.tool === 'link' ||
162
+ s.tool === 'mediaref' ||
162
163
  s.tool === 'section'
163
164
  );
164
165
  }
@@ -374,11 +375,13 @@ export function resizeStroke(
374
375
  start.tool === 'sticky' ||
375
376
  start.tool === 'polygon' ||
376
377
  start.tool === 'link' ||
378
+ start.tool === 'mediaref' ||
377
379
  start.tool === 'section'
378
380
  ) {
379
- // Rect / polygon / sticky / link all resize via their shared x / y / w / h
380
- // bbox. Text re-wraps inside the foreignObject automatically. Sticky stays
381
- // 1:1; the link card free-resizes (Shift still locks its current ratio).
381
+ // Rect / polygon / sticky / link / mediaref all resize via their shared
382
+ // x / y / w / h bbox. Sticky stays 1:1; the link + media-ref cards
383
+ // free-resize (Shift still locks their current ratio) so the user can size
384
+ // the inline player up (DDR-150 dogfood — "video annotation nejde zvětšit").
382
385
  const b0 = { x: start.x, y: start.y, w: start.w, h: start.h };
383
386
  const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
384
387
  return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * @file use-canvas-media-drop.tsx — Phase 23 canvas media intake
3
3
  * @scope apps/studio/use-canvas-media-drop.tsx
4
- * @purpose OS-level drag-and-drop + clipboard paste of images and URLs onto
5
- * the canvas. Routes each gesture to a create callback owned by
6
- * AnnotationsLayer (which holds the commit/undo sink + screenToWorld):
4
+ * @purpose OS-level drag-and-drop + clipboard paste of images, video/audio,
5
+ * and URLs onto the canvas. Routes each gesture to a create callback
6
+ * owned by AnnotationsLayer (which holds the commit/undo sink +
7
+ * screenToWorld):
7
8
  * • image file → `onImage(file, world)` → optimistic stroke + upload
9
+ * • video/audio → `onMedia?(file, kind, world)` → upload to assets/
10
+ * + toast the `<Video>`/`<Audio>` snippet (DDR-148; auto-insert
11
+ * into the comp TSX is a documented follow-up)
8
12
  * • http(s) URL → `onLink(url, title, world)` → client-only link chip
9
13
  *
10
14
  * The classification + URL helpers are PURE + exported so the unit
@@ -35,6 +39,7 @@ export interface MediaPayload {
35
39
 
36
40
  export type MediaIntent =
37
41
  | { kind: 'image'; file: File }
42
+ | { kind: 'media'; file: File; mediaKind: 'video' | 'audio' }
38
43
  | { kind: 'link'; url: string; title: string };
39
44
 
40
45
  /** True only for an absolute http(s) URL — the one scheme a link chip accepts. */
@@ -136,6 +141,19 @@ export function anchorTextFromHtml(html: string): string | null {
136
141
  export function classifyMediaPayload(p: MediaPayload): MediaIntent | null {
137
142
  const imageFile = p.files.find((f) => typeof f.type === 'string' && f.type.startsWith('image/'));
138
143
  if (imageFile) return { kind: 'image', file: imageFile };
144
+ // DDR-148 — video/audio files land in the project's assets/ (the widened asset
145
+ // route, DDR-088) so a comp can reference them as <Video>/<Audio src="assets/…">.
146
+ const mediaFile = p.files.find(
147
+ (f) =>
148
+ typeof f.type === 'string' && (f.type.startsWith('video/') || f.type.startsWith('audio/'))
149
+ );
150
+ if (mediaFile) {
151
+ return {
152
+ kind: 'media',
153
+ file: mediaFile,
154
+ mediaKind: mediaFile.type.startsWith('video/') ? 'video' : 'audio',
155
+ };
156
+ }
139
157
  const url = firstHttpUrl(p.uriList) ?? firstHttpUrl(p.plain);
140
158
  if (url) {
141
159
  const title = anchorTextFromHtml(p.html) ?? prettifyUrl(url);
@@ -242,6 +260,45 @@ export function showCanvasToast(message: string): void {
242
260
  export interface MediaDropCallbacks {
243
261
  onImage: (file: File, world: [number, number]) => void;
244
262
  onLink: (url: string, title: string, world: [number, number]) => void;
263
+ /**
264
+ * DDR-148 — a video/audio file was dropped. Optional: when omitted, the hook
265
+ * uploads it to `assets/` and toasts a ready-to-use `<Video>`/`<Audio>`
266
+ * snippet (the drop-then-reference flow). A future host can provide this to
267
+ * auto-insert the element into the composition TSX (the deferred slice — the
268
+ * comp-placement question, see DDR-148 open items).
269
+ */
270
+ onMedia?: (file: File, mediaKind: 'video' | 'audio', world: [number, number]) => void;
271
+ }
272
+
273
+ /** The snippet the toast surfaces so the dropped clip is one paste away. */
274
+ export function mediaSnippet(mediaKind: 'video' | 'audio', assetPath: string): string {
275
+ if (mediaKind === 'audio') return `<Audio src="${assetPath}" />`;
276
+ return `<Video src="${assetPath}" />`;
277
+ }
278
+
279
+ /**
280
+ * Default media-drop handler: upload the file to the widened asset route, then
281
+ * toast the `assets/…` path + the snippet to paste into the comp. Copies the
282
+ * snippet to the clipboard when available. Exported for reuse/testing.
283
+ */
284
+ export async function uploadAndAnnounceMedia(
285
+ file: File,
286
+ mediaKind: 'video' | 'audio'
287
+ ): Promise<void> {
288
+ const sizeMb = file.size / (1024 * 1024);
289
+ const res = await uploadAsset(file);
290
+ if ('error' in res) {
291
+ showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`);
292
+ return;
293
+ }
294
+ const snippet = mediaSnippet(mediaKind, res.path);
295
+ try {
296
+ await navigator.clipboard?.writeText(snippet);
297
+ } catch {
298
+ /* clipboard unavailable — the toast still shows the snippet */
299
+ }
300
+ const warn = sizeMb > 20 ? ' · ⚠ >20 MB rides git + sync' : '';
301
+ showCanvasToast(`Added ${res.path}${warn} — snippet copied: ${snippet}`);
245
302
  }
246
303
 
247
304
  export function useCanvasMediaDrop(opts: {
@@ -317,7 +374,12 @@ export function useCanvasMediaDrop(opts: {
317
374
 
318
375
  const dispatchIntent = (intent: MediaIntent, world: [number, number]) => {
319
376
  if (intent.kind === 'image') callbacks.onImage(intent.file, world);
320
- else if (isHttpUrl(intent.url)) callbacks.onLink(intent.url, intent.title, world);
377
+ else if (intent.kind === 'media') {
378
+ if (callbacks.onMedia) callbacks.onMedia(intent.file, intent.mediaKind, world);
379
+ else void uploadAndAnnounceMedia(intent.file, intent.mediaKind);
380
+ } else if (intent.kind === 'link' && isHttpUrl(intent.url)) {
381
+ callbacks.onLink(intent.url, intent.title, world);
382
+ }
321
383
  };
322
384
 
323
385
  document.addEventListener('dragover', onDragOver);