@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,444 @@
1
+ /**
2
+ * @file video-comp.tsx — DDR-148 video-comp canvas kind.
3
+ * @scope apps/studio/video-comp.tsx (re-exported from canvas-lib.tsx as
4
+ * part of the `@maude/canvas-lib` surface).
5
+ * @purpose Mount a Remotion composition inside `@remotion/player`'s `<Player>`
6
+ * for free scrub/preview, and expose the ONE deterministic seek
7
+ * bridge (`window.__maude_seek__`) that the capture spine
8
+ * (`bin/_video-playwright.mjs`) and the Timeline panel drive.
9
+ *
10
+ * A video-comp is deterministic BY CONSTRUCTION — every animated
11
+ * value is a pure function of `useCurrentFrame()`, so seeking the
12
+ * Player's `frame` renders one exact frame with no free-running
13
+ * clock. The capture shim steps `__maude_seek__(0..N)` and
14
+ * screenshots each frame; two runs to frame N are pixel-identical.
15
+ *
16
+ * Runtime wiring: `remotion` + `@remotion/player` are externalised
17
+ * through the canvas importmap (RUNTIME_PACKAGES / DDR-148), so the
18
+ * Player's provider stack and the composition's `useCurrentFrame()`
19
+ * bind to the SAME `remotion` module instance (no dual-package
20
+ * frozen-at-frame-0 hazard).
21
+ */
22
+
23
+ import { Player, type PlayerRef } from '@remotion/player';
24
+ import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react';
25
+
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+ // Comp meta contract + the module-level seek registry
28
+
29
+ /** The temporal contract a video-comp artboard declares. */
30
+ export interface VideoCompMeta {
31
+ /** Frames per second (Remotion timebase). */
32
+ fps: number;
33
+ /** Total composition length, in frames. */
34
+ durationInFrames: number;
35
+ /** Composition pixel width. */
36
+ width: number;
37
+ /** Composition pixel height. */
38
+ height: number;
39
+ }
40
+
41
+ /** A live registration — the Player handle + its meta, keyed by artboard id. */
42
+ interface CompEntry extends VideoCompMeta {
43
+ id: string;
44
+ ref: React.RefObject<PlayerRef | null>;
45
+ /** The Remotion composition component + its props — carried so the export
46
+ * capture's renderMediaOnWeb path (DDR-148 addendum, audio export) can
47
+ * render the SAME component tree Remotion owns the timeline math for
48
+ * (TransitionSeries offsets, <Audio volume={fn}> closures), rather than
49
+ * re-deriving it. Never serialized — component references don't survive
50
+ * postMessage/JSON, so this stays out of CompSnapshot. */
51
+ component: ComponentType<Record<string, unknown>>;
52
+ inputProps: Record<string, unknown>;
53
+ }
54
+
55
+ /** Serializable meta the capture shim + Timeline panel read (no functions). */
56
+ export interface CompSnapshot extends VideoCompMeta {
57
+ id: string;
58
+ }
59
+
60
+ /** Result contract for `__maude_render_video__` — see exporters/video-render-lib.ts. */
61
+ export interface RenderVideoResult {
62
+ b64: string;
63
+ bytes: number;
64
+ container: string;
65
+ videoCodec: string | null;
66
+ audioCodec: string | null;
67
+ }
68
+
69
+ /** Options accepted by `__maude_render_video__`, forwarded to renderMediaOnWeb. */
70
+ export interface RenderVideoOptions {
71
+ container: 'mp4' | 'webm';
72
+ videoCodec?: string;
73
+ audioCodec?: string;
74
+ scale?: number;
75
+ muted?: boolean;
76
+ frameRange?: [number, number];
77
+ licenseKey?: string;
78
+ }
79
+
80
+ interface MaudeSeekWindow {
81
+ __maudeVideoComps?: Map<string, CompEntry>;
82
+ /** Seek every registered comp to `frame` (paused). Ordinary artboards fall
83
+ * back to a time-based WAAPI seek. Resolves once the frame has painted. */
84
+ __maude_seek__?: (frame: number, opts?: { fps?: number }) => Promise<void>;
85
+ /** Serializable comp list for the capture shim / Timeline panel. */
86
+ __maude_comps__?: () => CompSnapshot[];
87
+ /**
88
+ * DDR-148 addendum (audio export) — whole-comp render via renderMediaOnWeb
89
+ * (video+audio, one pass; Remotion owns the TransitionSeries/volume-closure
90
+ * timeline math). Looks up the component by compId and delegates to
91
+ * `window.__maudeRenderVideo__`, which the export capture injects via
92
+ * addScriptTag (exporters/video-render-lib.ts) — absent during normal
93
+ * preview, so this throws outside a capture session.
94
+ */
95
+ __maude_render_video__?: (compId: string, opts: RenderVideoOptions) => Promise<RenderVideoResult>;
96
+ __maudeRenderVideo__?: (
97
+ opts: RenderVideoOptions & {
98
+ component: ComponentType<Record<string, unknown>>;
99
+ inputProps: Record<string, unknown>;
100
+ width: number;
101
+ height: number;
102
+ fps: number;
103
+ durationInFrames: number;
104
+ }
105
+ ) => Promise<RenderVideoResult>;
106
+ }
107
+
108
+ function seekWindow(): MaudeSeekWindow | null {
109
+ return typeof window === 'undefined' ? null : (window as unknown as MaudeSeekWindow);
110
+ }
111
+
112
+ function registry(): Map<string, CompEntry> {
113
+ const w = seekWindow();
114
+ if (!w) return new Map();
115
+ if (!w.__maudeVideoComps) w.__maudeVideoComps = new Map();
116
+ return w.__maudeVideoComps;
117
+ }
118
+
119
+ function clamp(n: number, lo: number, hi: number): number {
120
+ return Math.max(lo, Math.min(hi, n));
121
+ }
122
+
123
+ /** Two rAFs — enough for the Player to commit a seeked frame to the DOM. */
124
+ function afterPaint(): Promise<void> {
125
+ if (typeof requestAnimationFrame !== 'function') return Promise.resolve();
126
+ return new Promise((resolve) => {
127
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Install the singleton seek bridge on `window`. Idempotent — the first
133
+ * VideoComp to mount wins; later mounts reuse the same functions. The bridge
134
+ * reads the live registry, so it always reflects the currently-mounted comps.
135
+ * Exported so tests (and a defensive capture-shim call) can install it without
136
+ * mounting a Player.
137
+ */
138
+ export function installMaudeSeekBridge(): void {
139
+ const w = seekWindow();
140
+ if (!w || w.__maude_seek__) return;
141
+
142
+ w.__maude_seek__ = async (frame, opts) => {
143
+ const entries = [...registry().values()];
144
+ if (entries.length > 0) {
145
+ for (const e of entries) {
146
+ const f = clamp(Math.round(frame), 0, Math.max(0, e.durationInFrames - 1));
147
+ try {
148
+ e.ref.current?.pause();
149
+ e.ref.current?.seekTo(f);
150
+ } catch {
151
+ /* Player not yet ready — a later seek settles it */
152
+ }
153
+ }
154
+ await afterPaint();
155
+ return;
156
+ }
157
+ // Ordinary-artboard fallback (no registered comp): drive WAAPI by time so a
158
+ // manual `__maude_seek__` still works. The capture shim owns the primary
159
+ // ordinary-artboard path (it seeks <video>.currentTime + getAnimations()).
160
+ const fps = opts?.fps ?? 30;
161
+ const ms = (frame / fps) * 1000;
162
+ if (typeof document !== 'undefined') {
163
+ for (const a of document.getAnimations()) {
164
+ try {
165
+ a.pause();
166
+ a.currentTime = ms;
167
+ } catch {
168
+ /* unseekable animation — skip */
169
+ }
170
+ }
171
+ }
172
+ await afterPaint();
173
+ };
174
+
175
+ w.__maude_comps__ = () =>
176
+ [...registry().values()].map((e) => ({
177
+ id: e.id,
178
+ fps: e.fps,
179
+ durationInFrames: e.durationInFrames,
180
+ width: e.width,
181
+ height: e.height,
182
+ }));
183
+
184
+ w.__maude_render_video__ = async (compId, opts) => {
185
+ const entry = registry().get(compId);
186
+ if (!entry) throw new Error(`no registered video-comp with id "${compId}"`);
187
+ if (typeof w.__maudeRenderVideo__ !== 'function') {
188
+ throw new Error(
189
+ 'render-lib bundle not injected — the export capture must addScriptTag ' +
190
+ 'exporters/video-render-lib.ts before calling __maude_render_video__'
191
+ );
192
+ }
193
+ return w.__maudeRenderVideo__({
194
+ ...opts,
195
+ component: entry.component,
196
+ inputProps: entry.inputProps,
197
+ width: entry.width,
198
+ height: entry.height,
199
+ fps: entry.fps,
200
+ durationInFrames: entry.durationInFrames,
201
+ });
202
+ };
203
+ }
204
+
205
+ /** Find the compId of the nearest VideoComp mounted inside `container` (the
206
+ * target artboard element) — the artboard's own `data-dc-screen` id and the
207
+ * VideoComp's `id` prop are independent (an author rarely sets the latter),
208
+ * so the capture shim resolves by DOM containment rather than assuming a
209
+ * 1:1 id match. Returns null when the artboard has no video-comp (an
210
+ * ordinary artboard, or a comp not yet mounted). */
211
+ export function findCompIdIn(container: Element): string | null {
212
+ return container.querySelector('[data-comp-id]')?.getAttribute('data-comp-id') ?? null;
213
+ }
214
+
215
+ // ─────────────────────────────────────────────────────────────────────────────
216
+ // VideoComp — the canvas-lib wrapper
217
+
218
+ export interface VideoCompProps extends VideoCompMeta {
219
+ /** The Remotion composition component (frame-driven; NO CSS animations). */
220
+ component: ComponentType<Record<string, unknown>>;
221
+ /** Stable id (defaults to a mount-order slug) — keys the seek registry + the
222
+ * Timeline rows; match the enclosing DCArtboard id when there are several. */
223
+ id?: string;
224
+ /** Props forwarded to the composition (Remotion `inputProps`). */
225
+ inputProps?: Record<string, unknown>;
226
+ /** Show the Player's own transport chrome in PREVIEW. Auto-hidden during
227
+ * capture/export (the `?hide-chrome=1` shell flag). Default true. */
228
+ controls?: boolean;
229
+ /** Loop preview playback. Default true. */
230
+ loop?: boolean;
231
+ /**
232
+ * Autoplay in preview. Default: play unless the viewer prefers reduced motion
233
+ * (a11y) or the shell is in capture mode. Never autoplays during export.
234
+ */
235
+ autoPlay?: boolean;
236
+ }
237
+
238
+ let mountCounter = 0;
239
+
240
+ function prefersReducedMotion(): boolean {
241
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
242
+ try {
243
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+
249
+ /** True when the shell asked for a chrome-free render (export capture / diff). */
250
+ function hideChrome(): boolean {
251
+ if (typeof window === 'undefined') return false;
252
+ try {
253
+ return new URLSearchParams(window.location.search).get('hide-chrome') === '1';
254
+ } catch {
255
+ return false;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Mount a Remotion composition as a scrubbable video-comp artboard body. Place
261
+ * it inside a `DCArtboard` whose width/height match the composition:
262
+ *
263
+ * ```tsx
264
+ * const Intro = () => {
265
+ * const frame = useCurrentFrame();
266
+ * const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' });
267
+ * return <AbsoluteFill style={{ opacity }}>…</AbsoluteFill>;
268
+ * };
269
+ * // …inside the canvas:
270
+ * <DCArtboard id="intro" label="Intro" width={640} height={360}>
271
+ * <VideoComp component={Intro} durationInFrames={90} fps={30} width={640} height={360} />
272
+ * </DCArtboard>
273
+ * ```
274
+ */
275
+ export function VideoComp({
276
+ component,
277
+ durationInFrames,
278
+ fps,
279
+ width,
280
+ height,
281
+ id,
282
+ inputProps,
283
+ controls,
284
+ loop = true,
285
+ autoPlay,
286
+ }: VideoCompProps) {
287
+ const ref = useRef<PlayerRef | null>(null);
288
+ // Stable id across re-renders; unique per mount when the author omits one.
289
+ const [autoId] = useState(() => id ?? `videocomp-${++mountCounter}`);
290
+ const compId = id ?? autoId;
291
+ // Loop is live-controlled from the Timeline (timeline-loop message), not just
292
+ // the mount prop — so toggling loop OFF actually stops the replay.
293
+ const [loopState, setLoopState] = useState(loop);
294
+ const capturing = hideChrome();
295
+ // The Timeline panel is the control surface (transport + scrub + volume), so
296
+ // the Player's own chrome is redundant on the artboard — hidden by default.
297
+ // Opt back in per-comp with `controls`; never shown during capture. (DDR-148.)
298
+ const showControls = (controls ?? false) && !capturing;
299
+ // Default to PAUSED in preview so the comp is editable on open (a moving,
300
+ // frame-driven element can't be Cmd+Click-selected). The author can opt into
301
+ // autoplay; capture never autoplays (the shim drives frames). Opens on a
302
+ // poster frame ~35% in so a paused comp shows content, not an empty frame 0.
303
+ const play = (autoPlay ?? false) && !capturing;
304
+ const posterFrame = capturing
305
+ ? 0
306
+ : Math.min(Math.max(0, durationInFrames - 1), Math.round(durationInFrames * 0.35));
307
+
308
+ // Register with the seek bridge for the lifetime of this mount.
309
+ useEffect(() => {
310
+ installMaudeSeekBridge();
311
+ const entry: CompEntry = {
312
+ id: compId,
313
+ fps,
314
+ durationInFrames,
315
+ width,
316
+ height,
317
+ ref,
318
+ component,
319
+ inputProps: inputProps ?? {},
320
+ };
321
+ registry().set(compId, entry);
322
+ // Announce comps to the shell so the Timeline panel can auto-suggest.
323
+ postToShell({ dgn: 'timeline-comps', comps: seekWindow()?.__maude_comps__?.() ?? [] });
324
+ return () => {
325
+ const cur = registry().get(compId);
326
+ if (cur === entry) registry().delete(compId);
327
+ postToShell({ dgn: 'timeline-comps', comps: seekWindow()?.__maude_comps__?.() ?? [] });
328
+ };
329
+ }, [compId, fps, durationInFrames, width, height, component, inputProps]);
330
+
331
+ // Shell → iframe transport: the Timeline panel drives preview scrub/playback.
332
+ useEffect(() => {
333
+ if (typeof window === 'undefined') return;
334
+ const onMessage = (e: MessageEvent) => {
335
+ const m = e.data as {
336
+ dgn?: string;
337
+ frame?: number;
338
+ id?: string;
339
+ muted?: boolean;
340
+ volume?: number;
341
+ loop?: boolean;
342
+ } | null;
343
+ if (!m || typeof m !== 'object' || typeof m.dgn !== 'string') return;
344
+ // The shell asks a (possibly already-mounted) canvas to re-announce its
345
+ // comps — answer regardless of Player readiness so a tab-switch / panel-
346
+ // open re-syncs the Timeline panel.
347
+ if (m.dgn === 'timeline-request-comps') {
348
+ postToShell({ dgn: 'timeline-comps', comps: seekWindow()?.__maude_comps__?.() ?? [] });
349
+ return;
350
+ }
351
+ if (m.id && m.id !== compId) return; // targeted seek to another comp
352
+ const p = ref.current;
353
+ if (!p) return;
354
+ try {
355
+ if (m.dgn === 'timeline-seek' && typeof m.frame === 'number') {
356
+ p.pause();
357
+ p.seekTo(clamp(Math.round(m.frame), 0, Math.max(0, durationInFrames - 1)));
358
+ } else if (m.dgn === 'timeline-play') {
359
+ p.play();
360
+ } else if (m.dgn === 'timeline-pause') {
361
+ p.pause();
362
+ } else if (m.dgn === 'timeline-mute') {
363
+ // Sound is controlled from the Timeline (the artboard has no chrome).
364
+ if (m.muted) p.mute();
365
+ else p.unmute();
366
+ } else if (m.dgn === 'timeline-volume' && typeof m.volume === 'number') {
367
+ p.setVolume(clamp(m.volume, 0, 1));
368
+ } else if (m.dgn === 'timeline-loop') {
369
+ setLoopState(!!m.loop);
370
+ }
371
+ } catch {
372
+ /* Player transitional — ignore */
373
+ }
374
+ };
375
+ window.addEventListener('message', onMessage);
376
+ return () => window.removeEventListener('message', onMessage);
377
+ }, [compId, durationInFrames]);
378
+
379
+ // iframe → shell: mirror the playhead so the Timeline panel tracks preview.
380
+ useEffect(() => {
381
+ const p = ref.current;
382
+ if (!p) return;
383
+ const onFrame = () => {
384
+ try {
385
+ postToShell({ dgn: 'timeline-frame', id: compId, frame: p.getCurrentFrame() });
386
+ } catch {
387
+ /* ignore */
388
+ }
389
+ };
390
+ p.addEventListener('frameupdate', onFrame);
391
+ return () => {
392
+ try {
393
+ p.removeEventListener('frameupdate', onFrame);
394
+ } catch {
395
+ /* ignore */
396
+ }
397
+ };
398
+ }, [compId]);
399
+
400
+ const style = useMemo(() => ({ width: '100%', height: '100%', display: 'block' as const }), []);
401
+
402
+ return (
403
+ <div
404
+ className="dc-video-comp"
405
+ data-comp-id={compId}
406
+ style={{ width, height, maxWidth: '100%' }}
407
+ >
408
+ <Player
409
+ ref={ref}
410
+ component={component}
411
+ durationInFrames={Math.max(1, Math.round(durationInFrames))}
412
+ fps={fps}
413
+ compositionWidth={width}
414
+ compositionHeight={height}
415
+ inputProps={inputProps ?? {}}
416
+ controls={showControls}
417
+ loop={loopState}
418
+ autoPlay={play}
419
+ initialFrame={posterFrame}
420
+ // Clicking the artboard must NOT toggle playback — it selects elements
421
+ // for editing (the whole point of a canvas). Play only via the explicit
422
+ // transport button (Player controls / Timeline panel). Same for the
423
+ // spacebar, which is the canvas pan chord. (DDR-148 feedback #2.)
424
+ clickToPlay={false}
425
+ spaceKeyToPlayOrPause={false}
426
+ doubleClickToFullscreen={false}
427
+ // We ship Remotion in-house and disclose the license (DDR-148); ack in
428
+ // code so the Player never nags/watermarks the preview.
429
+ acknowledgeRemotionLicense
430
+ style={style}
431
+ />
432
+ </div>
433
+ );
434
+ }
435
+ VideoComp.displayName = 'VideoComp';
436
+
437
+ function postToShell(payload: Record<string, unknown>): void {
438
+ if (typeof window === 'undefined' || window.parent === window) return;
439
+ try {
440
+ window.parent.postMessage(payload, '*');
441
+ } catch {
442
+ /* cross-origin parent without our listener — no-op */
443
+ }
444
+ }
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "desktop-intel-mac-support",
6
+ "version": "0.41.0",
7
+ "date": "2026-07-08",
8
+ "kind": "improvement",
9
+ "title": "Desktop app now runs on Intel Macs",
10
+ "summary": "The macOS desktop app was Apple-Silicon-only — it now ships as a universal binary, so Intel Mac users can download and run it natively too, no Rosetta workaround needed.",
11
+ "learnMore": "https://maude.sh/desktop",
12
+ "surface": "design-ui"
13
+ },
14
+ {
15
+ "id": "element-editing-robustness",
16
+ "version": "0.41.0",
17
+ "date": "2026-07-08",
18
+ "kind": "feature",
19
+ "title": "Edit elements like Figma or Webflow",
20
+ "summary": "Selecting an element now opens the Inspector automatically, and drag handles let you resize it directly on the canvas (corner + edge, Shift to lock aspect, Alt to resize from center) — artboards get free-hand resize too. Delete an element with the Del key or the right-click menu, or insert a new div, text, or image straight into the artboard; add a whole new empty artboard from a Desktop/Laptop/Tablet/Mobile preset. The Inspector gained Position (with a constraints-style inset box), Transform, and Media sections (object-fit, aspect ratio), plus on-canvas dragging for padding and gaps. Swap a photo or video (or a background image) from a built-in asset picker. Editing now works on design-system specimens too, not just your UI canvases. A shared component always tells you plainly whether an edit is local to this artboard or shared across every place it's used, and moving one instance never drags the others along. Rounding it out: keyboard nudge + tree navigation, Cmd+D duplicate, copy/paste style, align and distribute a multi-selection, deep-select + \"Select layer\" for nested/overlapping elements, Alt-hover distance measurement, a free-hand rotate handle, and a proper Fixed/Hug/Fill sizing control for flex layouts. Every one of these is undoable with Cmd+Z.",
21
+ "surface": "design-ui"
22
+ },
23
+ {
24
+ "id": "whiteboard-ai-toolkit",
25
+ "version": "0.41.0",
26
+ "date": "2026-07-08",
27
+ "kind": "feature",
28
+ "title": "The Assistant now reads and draws on your whiteboard",
29
+ "summary": "Sketch stickies, shapes, and arrows on a canvas and the Assistant understands not just which artboard a note sits on, but which button or element it's pointing at. Ask it to pin a note next to something specific, and it'll drop it right there with an arrow pointing at it — no guessing coordinates. It can also lay out a whole tidy board for you: a sprint retro, a kanban, a week of social posts, a roadmap, a brainstorm, a user-flow diagram — just ask.",
30
+ "surface": "design-ui"
31
+ },
32
+ {
33
+ "id": "timeline-clip-editing",
34
+ "version": "0.40.0",
35
+ "date": "2026-07-05",
36
+ "kind": "feature",
37
+ "title": "Hand-edit your video comps on the timeline",
38
+ "summary": "After the Assistant generates a video, fine-tune it yourself. On the timeline you can drag a clip to move it, drag its right edge to trim (both snap to second-ticks and neighbour clip edges; hold Alt to drag freely), drop a video / image / audio file to add a clip, raise or lower a clip's stacking, swap a clip's footage (⇄), or remove a clip (×). Right-click any clip for the same menu — replace, move, hide, or remove — and hide keeps a clip in place while it stops rendering. Clips that layer a background over a title expand into per-layer rows, so you can see and replace the video separately from its caption. Transition-based showreels reorder and accept dropped clips too. Keyboard shortcuts help: Space plays/pauses, arrows step frames (Shift = a second), Home/End jump to the ends, and . / , hop between keyframes. Prefer to start from footage? Drop video/audio clips anywhere on the canvas as reference chips, then File then Assemble dropped clips into video builds a real, editable comp in one click. Editing is multi-comp-safe — a change always lands on the clip you meant, never one in another composition. And inline text / CSS tweaks you make on the canvas now actually stick.",
39
+ "surface": "design-ui"
40
+ },
41
+ {
42
+ "id": "video-comp-remotion",
43
+ "version": "0.40.0",
44
+ "date": "2026-07-05",
45
+ "kind": "feature",
46
+ "title": "Design videos & animations, right in the canvas",
47
+ "summary": "A canvas artboard can now be a real motion composition (powered by Remotion) — scrub it in the built-in player, drop clips and music onto it, direct edits from the Assistant (\"turn this into an animation\", \"join these 4 clips with a crossfade\"), and export the artboard as MP4 or GIF. Rendered by Maude's own capture engine — no extra install, no native downloads. Remotion is free for individuals and teams of up to 3; larger orgs need their own Remotion license.",
48
+ "surface": "design-ui"
49
+ },
50
+ {
51
+ "id": "file-tree-live-config-reload",
52
+ "version": "0.40.0",
53
+ "date": "2026-07-05",
54
+ "kind": "fix",
55
+ "title": "New design systems show up in the file tree instantly",
56
+ "summary": "Bootstrapping a design system from the Assistant (or editing your design config by hand) now updates the file tree live — new canvas groups and their files appear as they're created, no server restart needed.",
57
+ "surface": "design-ui"
58
+ },
4
59
  {
5
60
  "id": "acp-chat-cross-restart-resume",
6
61
  "version": "0.39.1",
package/apps/studio/ws.ts CHANGED
@@ -206,6 +206,11 @@ export function createWs(
206
206
  broadcast({ type: 'canvas-list-update', payload })
207
207
  );
208
208
 
209
+ // Config hot-reload (server.ts fs:json subscriber) — the shell refetches
210
+ // /_config so cfg-derived state (designSystems, tokensCssRel, canvasGroups)
211
+ // matches the reloaded server config. Inspector clients only.
212
+ ctx.bus.on('config-updated', () => broadcast({ type: 'config-updated' }));
213
+
209
214
  // Phase 31 (DDR-123) — `/design:chat` → `maude design chat-open` → POST
210
215
  // /_api/acp/focus emits this; the shell (app.jsx, native-only) opens the
211
216
  // Assistant panel. Inspector clients only — same-origin shell, like the rest.
@@ -43,8 +43,10 @@ const BIN_VERBS = new Set([
43
43
  'to-lottie',
44
44
  'read-annotations',
45
45
  'annotate',
46
+ 'canvas-rects',
46
47
  'chat-open',
47
48
  'ensure-browser',
49
+ 'fetch-asset',
48
50
  ]);
49
51
 
50
52
  // Bin verbs that boot the dev-server (directly, or by shelling into server-up.sh).
@@ -136,8 +138,9 @@ Lifecycle:
136
138
 
137
139
  Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
138
140
  screenshot · server-up · prep · slug · bootstrap-check · runtime-health
139
- smoke · canvas-edit · handoff · asset-sweep · visual-sanity
141
+ smoke · canvas-edit · handoff · asset-sweep · visual-sanity · fetch-asset
140
142
  draw-build · draw-proof · svg-optimize · to-lottie · read-annotations · annotate
143
+ canvas-rects
141
144
  Invoke the bundled helper of the same name. maude resolves it from its
142
145
  own package root and sets CLAUDE_PLUGIN_ROOT for the child; stdout,
143
146
  stderr, and exit code pass straight through (so command-substitution
@@ -147,6 +150,8 @@ Dev-tooling (dispatch to the dev-server bash helpers — DDR-062):
147
150
  PORT=$(maude design server-up --root "$REPO")
148
151
  eval "$(maude design prep --shell-export --shape edit --root "$REPO")"
149
152
  maude design smoke --changed-only
153
+ REF=$(maude design fetch-asset "$URL" --root "$REPO") # download-first image
154
+ maude design canvas-rects "ui/Foo.tsx" # geometry manifest (whiteboard toolkit)
150
155
 
151
156
  serve [--port N] [--root PATH]
152
157
  Start the design plugin's dev server in the current repo. Equivalent
@@ -51,11 +51,59 @@ test('unknown verb exits 2', () => {
51
51
  test('design help lists the dev-tooling verbs', () => {
52
52
  const res = runDesign(['help']);
53
53
  assert.equal(res.status, 0, res.stderr);
54
- for (const verb of ['screenshot', 'server-up', 'prep', 'slug', 'smoke', 'visual-sanity']) {
54
+ for (const verb of [
55
+ 'screenshot',
56
+ 'server-up',
57
+ 'prep',
58
+ 'slug',
59
+ 'smoke',
60
+ 'visual-sanity',
61
+ 'canvas-rects',
62
+ ]) {
55
63
  assert.match(res.stdout, new RegExp(verb), `usage should mention ${verb}`);
56
64
  }
57
65
  });
58
66
 
67
+ // canvas-rects needs an explicit --root pinned to an isolated temp dir (not
68
+ // runDesign's ambient cwd) — its server-detection reads $CLAUDE_PROJECT_DIR /
69
+ // `git rev-parse` when --root is absent, which would escape the temp dir in
70
+ // an environment where either is set.
71
+ function runCanvasRects(args, root) {
72
+ const env = { ...process.env };
73
+ env.CLAUDE_PLUGIN_ROOT = undefined;
74
+ return spawnSync(process.execPath, [BIN, 'design', 'canvas-rects', ...args, '--root', root], {
75
+ cwd: root,
76
+ encoding: 'utf8',
77
+ env,
78
+ });
79
+ }
80
+
81
+ test('canvas-rects dispatches to the bundled helper (--help)', () => {
82
+ const root = mkdtempSync(join(tmpdir(), 'maude-canvas-rects-'));
83
+ try {
84
+ const res = runCanvasRects(['--help'], root);
85
+ assert.equal(res.status, 0, res.stderr);
86
+ assert.match(res.stdout, /canvas-rects\.sh/);
87
+ } finally {
88
+ rmSync(root, { recursive: true, force: true });
89
+ }
90
+ });
91
+
92
+ test('canvas-rects with no live server falls back to the static lane', () => {
93
+ // No .design/_server.json under --root → static fallback, no browser
94
+ // needed. A missing canvas still emits a valid empty manifest (exit 0).
95
+ const root = mkdtempSync(join(tmpdir(), 'maude-canvas-rects-'));
96
+ try {
97
+ const res = runCanvasRects(['ui/Nonexistent.tsx'], root);
98
+ assert.equal(res.status, 0, res.stderr);
99
+ assert.match(res.stderr, /static/);
100
+ const manifest = JSON.parse(res.stdout.trim());
101
+ assert.deepEqual(manifest, { artboards: [], elements: [], elementsTruncated: false });
102
+ } finally {
103
+ rmSync(root, { recursive: true, force: true });
104
+ }
105
+ });
106
+
59
107
  // resolveServerBinary — the dev-server boot-runtime resolver (DDR-084). server-up
60
108
  // must boot the compiled platform binary on a production install (it embeds yjs +
61
109
  // every dep; `bun server.ts` from source can't resolve them — the historic