@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
@@ -345,7 +345,13 @@ export function isEditableTarget(t: EventTarget | null): boolean {
345
345
  */
346
346
  export function isOverlayTarget(t: EventTarget | null): boolean {
347
347
  if (!t || !(t as Element).closest) return false;
348
- return !!(t as Element).closest('.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin');
348
+ // [data-mediaref-player] the inline <video>/<audio controls> on a media
349
+ // reference chip (DDR-150 dogfood #8). The router must never claim (and
350
+ // preventDefault) pointerdowns over the player, or its native controls
351
+ // (play button, scrubber drag, volume) die under the move tool.
352
+ return !!(t as Element).closest(
353
+ '.cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, [data-mediaref-player]'
354
+ );
349
355
  }
350
356
 
351
357
  export function useInputRouter(opts: UseInputRouterOptions): void {
@@ -469,6 +475,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
469
475
  if (action.kind !== 'no-op') {
470
476
  e.preventDefault();
471
477
  e.stopImmediatePropagation();
478
+ // preventDefault above stops mousedown's default FOCUS, so a Cmd-click
479
+ // select would leave the iframe unfocused and every in-canvas keyboard
480
+ // shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
481
+ // drag happened to focus it (dogfood: "funguje jen když hnu myší").
482
+ // Restore canvas focus explicitly so the keydown listeners fire.
483
+ if (action.kind === 'select') {
484
+ try {
485
+ window.focus();
486
+ } catch {
487
+ /* focus may be rejected outside a user gesture */
488
+ }
489
+ }
472
490
  }
473
491
  };
474
492
 
@@ -54,7 +54,7 @@ function isChromeTarget(t: EventTarget | null): boolean {
54
54
  const el = t as Element;
55
55
  if (
56
56
  el.closest(
57
- '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
57
+ '.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-el-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
58
58
  )
59
59
  ) {
60
60
  return true;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * @file measure-overlay.tsx — Task L7 (feature-element-editing-robustness)
3
+ * @scope apps/studio/measure-overlay.tsx
4
+ * @purpose Alt-hover distance measurement (Figma parity): with exactly one
5
+ * element selected, holding Alt and hovering a DIFFERENT stamped
6
+ * element paints a red guide line + pixel-distance pill between
7
+ * the two boxes' facing edges, on whichever axis(es) actually
8
+ * have a gap. Read-only — never writes anything, no undo, no
9
+ * write-surface implications.
10
+ *
11
+ * Mirrors `equal-spacing-handles.tsx`'s fixed-rAF-follow scaffold
12
+ * (persistent DOM children, no React re-render per frame) and
13
+ * reuses its pairwise-gap sibling `computePairGap`
14
+ * (equal-spacing-detector.ts) for the math.
15
+ *
16
+ * Alt-tracking mirrors `use-cursor-modifiers.tsx`'s
17
+ * `reduceModifiers` reducer, but keeps the result in React state
18
+ * (gating hover RESOLUTION, a real cost) rather than a DOM
19
+ * attribute (that hook only drives CSS cursor swaps).
20
+ *
21
+ * Distances are printed in WORLD px (screen delta ÷ the world's
22
+ * zoom) so the number stays correct at any zoom level — the
23
+ * Stage-A host-scroll-0 invariant is what makes a screen-rect
24
+ * delta ÷ zoom equivalent to a true world-space measurement.
25
+ */
26
+
27
+ import { useEffect, useRef, useState } from 'react';
28
+ import { useArtboardsContext } from './canvas-lib.tsx';
29
+ import { resolveSelectionEl } from './dom-selection.ts';
30
+ import { computePairGap } from './equal-spacing-detector.ts';
31
+ import { resolveHoverTarget } from './input-router.tsx';
32
+ import { type ModifierState, reduceModifiers } from './use-cursor-modifiers.tsx';
33
+ import { useSelectionSet } from './use-selection-set.tsx';
34
+
35
+ const MEASURE_CSS = `
36
+ .dc-cv-measure-layer {
37
+ position: fixed;
38
+ inset: 0;
39
+ pointer-events: none;
40
+ z-index: 7;
41
+ }
42
+ .dc-cv-measure-line {
43
+ position: absolute;
44
+ background: #FF3B30;
45
+ opacity: 0;
46
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
47
+ }
48
+ .dc-cv-measure-line[data-axis="x"] { height: 1px; }
49
+ .dc-cv-measure-line[data-axis="y"] { width: 1px; }
50
+ .dc-cv-measure-line[data-on="true"] { opacity: 1; }
51
+ .dc-cv-measure-pill {
52
+ position: absolute;
53
+ transform: translate(-50%, -50%);
54
+ font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
55
+ font-size: 10px;
56
+ padding: 2px 5px;
57
+ background: #FF3B30;
58
+ color: #ffffff;
59
+ border-radius: 2px;
60
+ letter-spacing: 0.02em;
61
+ white-space: nowrap;
62
+ opacity: 0;
63
+ transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
64
+ }
65
+ .dc-cv-measure-pill[data-on="true"] { opacity: 1; }
66
+ @media (prefers-reduced-motion: reduce) {
67
+ .dc-cv-measure-line, .dc-cv-measure-pill { transition-duration: 1ms; }
68
+ }
69
+ `.trim();
70
+
71
+ function ensureStyles(): void {
72
+ if (typeof document === 'undefined') return;
73
+ if (document.getElementById('dc-cv-measure-css')) return;
74
+ const s = document.createElement('style');
75
+ s.id = 'dc-cv-measure-css';
76
+ s.textContent = MEASURE_CSS;
77
+ document.head.appendChild(s);
78
+ }
79
+
80
+ interface ScreenRect {
81
+ x: number;
82
+ y: number;
83
+ w: number;
84
+ h: number;
85
+ }
86
+
87
+ function rectOf(el: Element): ScreenRect | null {
88
+ const b = (el as HTMLElement).getBoundingClientRect();
89
+ if (b.width === 0 && b.height === 0) return null;
90
+ return { x: b.left, y: b.top, w: b.width, h: b.height };
91
+ }
92
+
93
+ /** Persistent line+pill pair for one axis — created once, toggled via
94
+ * `data-on` (opacity transition) rather than added/removed each frame. */
95
+ function ensureAxisNodes(layer: HTMLDivElement, axis: 'x' | 'y'): [HTMLDivElement, HTMLDivElement] {
96
+ let line = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-line[data-axis="${axis}"]`);
97
+ let pill = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-pill[data-axis="${axis}"]`);
98
+ if (!line) {
99
+ line = document.createElement('div');
100
+ line.className = 'dc-cv-measure-line';
101
+ line.dataset.axis = axis;
102
+ layer.appendChild(line);
103
+ }
104
+ if (!pill) {
105
+ pill = document.createElement('div');
106
+ pill.className = 'dc-cv-measure-pill';
107
+ pill.dataset.axis = axis;
108
+ layer.appendChild(pill);
109
+ }
110
+ return [line, pill];
111
+ }
112
+
113
+ function hideAxis(layer: HTMLDivElement, axis: 'x' | 'y'): void {
114
+ const [line, pill] = ensureAxisNodes(layer, axis);
115
+ line.dataset.on = 'false';
116
+ pill.dataset.on = 'false';
117
+ }
118
+
119
+ function paintAxis(
120
+ layer: HTMLDivElement,
121
+ axis: 'x' | 'y',
122
+ a: ScreenRect,
123
+ b: ScreenRect,
124
+ zoom: number
125
+ ): void {
126
+ const g = computePairGap(a, b, axis);
127
+ const [line, pill] = ensureAxisNodes(layer, axis);
128
+ if (!g) {
129
+ line.dataset.on = 'false';
130
+ pill.dataset.on = 'false';
131
+ return;
132
+ }
133
+ const worldGap = Math.round(g.gap / (zoom || 1));
134
+ if (axis === 'x') {
135
+ line.style.left = `${Math.round(g.from)}px`;
136
+ line.style.top = `${Math.round(g.cross)}px`;
137
+ line.style.width = `${Math.round(g.gap)}px`;
138
+ pill.style.left = `${Math.round(g.from + g.gap / 2)}px`;
139
+ pill.style.top = `${Math.round(g.cross)}px`;
140
+ } else {
141
+ line.style.left = `${Math.round(g.cross)}px`;
142
+ line.style.top = `${Math.round(g.from)}px`;
143
+ line.style.height = `${Math.round(g.gap)}px`;
144
+ pill.style.left = `${Math.round(g.cross)}px`;
145
+ pill.style.top = `${Math.round(g.from + g.gap / 2)}px`;
146
+ }
147
+ pill.textContent = `${worldGap}`;
148
+ line.dataset.on = 'true';
149
+ pill.dataset.on = 'true';
150
+ }
151
+
152
+ export function MeasureOverlay() {
153
+ ensureStyles();
154
+ const { selected } = useSelectionSet();
155
+ const world = useArtboardsContext();
156
+ const layerRef = useRef<HTMLDivElement | null>(null);
157
+ const [alt, setAlt] = useState(false);
158
+ const [hoverEl, setHoverEl] = useState<Element | null>(null);
159
+
160
+ // Alt + hover tracking, gated together: hover RESOLUTION (a real per-move
161
+ // cost — resolveHoverTarget walks the DOM) only runs while Alt is held.
162
+ useEffect(() => {
163
+ if (typeof document === 'undefined') return;
164
+ let mods: ModifierState = { alt: false, shift: false, meta: false };
165
+ const onKeyChange = (e: KeyboardEvent) => {
166
+ const next = reduceModifiers(mods, e);
167
+ if (next === mods) return;
168
+ mods = next;
169
+ setAlt(mods.alt);
170
+ if (!mods.alt) setHoverEl(null);
171
+ };
172
+ const onPointer = (e: PointerEvent) => {
173
+ const next = reduceModifiers(mods, e);
174
+ if (next !== mods) {
175
+ mods = next;
176
+ setAlt(mods.alt);
177
+ }
178
+ if (!mods.alt) {
179
+ setHoverEl(null);
180
+ return;
181
+ }
182
+ const t = resolveHoverTarget(document, e.clientX, e.clientY, { deep: true });
183
+ setHoverEl(t?.el ?? null);
184
+ };
185
+ const onBlur = () => {
186
+ mods = { alt: false, shift: false, meta: false };
187
+ setAlt(false);
188
+ setHoverEl(null);
189
+ };
190
+ document.addEventListener('keydown', onKeyChange, true);
191
+ document.addEventListener('keyup', onKeyChange, true);
192
+ document.addEventListener('pointermove', onPointer, { passive: true });
193
+ window.addEventListener('blur', onBlur);
194
+ return () => {
195
+ document.removeEventListener('keydown', onKeyChange, true);
196
+ document.removeEventListener('keyup', onKeyChange, true);
197
+ document.removeEventListener('pointermove', onPointer);
198
+ window.removeEventListener('blur', onBlur);
199
+ };
200
+ }, []);
201
+
202
+ const one = selected.length === 1 ? selected[0] : null;
203
+ const zoom = world?.viewport?.zoom || 1;
204
+ const active = alt && !!one && !!hoverEl;
205
+
206
+ useEffect(() => {
207
+ const layer = layerRef.current;
208
+ if (!layer) return;
209
+ const hideBoth = () => {
210
+ hideAxis(layer, 'x');
211
+ hideAxis(layer, 'y');
212
+ };
213
+ if (!active || !one || !hoverEl) {
214
+ hideBoth();
215
+ return;
216
+ }
217
+ let alive = true;
218
+ let rafId: number | null = null;
219
+ const tick = () => {
220
+ rafId = null;
221
+ if (!alive) return;
222
+ const selEl = resolveSelectionEl(document, one);
223
+ const a = selEl && selEl !== hoverEl && hoverEl.isConnected ? rectOf(selEl) : null;
224
+ const b = a ? rectOf(hoverEl) : null;
225
+ if (a && b) {
226
+ paintAxis(layer, 'x', a, b, zoom);
227
+ paintAxis(layer, 'y', a, b, zoom);
228
+ } else {
229
+ hideBoth();
230
+ }
231
+ rafId = requestAnimationFrame(tick);
232
+ };
233
+ rafId = requestAnimationFrame(tick);
234
+ return () => {
235
+ alive = false;
236
+ if (rafId != null) cancelAnimationFrame(rafId);
237
+ };
238
+ }, [active, one, hoverEl, zoom]);
239
+
240
+ return <div ref={layerRef} className="dc-cv-measure-layer" aria-hidden="true" />;
241
+ }
@@ -29,7 +29,7 @@ const CHROME_CSS = `
29
29
  display: flex;
30
30
  align-items: center;
31
31
  pointer-events: auto;
32
- font-family: var(--font-sans, system-ui, -apple-system, sans-serif);
32
+ font-family: system-ui, -apple-system, sans-serif;
33
33
  }
34
34
  .dc-participant {
35
35
  width: 26px;
@@ -74,7 +74,7 @@ const CHROME_CSS = `
74
74
  background: var(--maude-chrome-bg-1, #fff);
75
75
  color: var(--maude-chrome-fg-0, #111);
76
76
  border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
77
- border-radius: var(--radius-md, 4px);
77
+ border-radius: 4px;
78
78
  font-size: 12px;
79
79
  line-height: 1.35;
80
80
  white-space: nowrap;
@@ -93,7 +93,7 @@ const CHROME_CSS = `
93
93
  background: var(--maude-hud-accent, oklch(56% 0.170 50));
94
94
  color: var(--maude-hud-accent-fg, #fff);
95
95
  border: none;
96
- border-radius: var(--radius-sm, 2px);
96
+ border-radius: 2px;
97
97
  font-family: inherit;
98
98
  font-weight: 500;
99
99
  font-size: 11px;
@@ -79,6 +79,36 @@ export const RUNTIME_PACKAGES = [
79
79
  // every real canvas) returned HTTP 500 "Bundle failed".
80
80
  'lib0/decoding',
81
81
  'lib0/encoding',
82
+ // DDR-148 — Remotion video-comp authoring. `remotion` carries the React
83
+ // context stack (RemotionEnvironment / Sequence / Timeline) that a video-comp
84
+ // canvas AND the `<Player>` preview BOTH consume — so it MUST be a single
85
+ // externalised bundle (importmap-shared), exactly like React itself, or the
86
+ // Player's provider and the composition's `useCurrentFrame()` bind to
87
+ // different context instances and the comp renders frozen at frame 0 (the
88
+ // dual-package hazard). `@remotion/player` + `@remotion/transitions` each
89
+ // externalise `remotion` (→ the one bundle) and INLINE their own stateless
90
+ // helpers — `remotion/no-react` (interpolate/random/validators/NoReactInternals,
91
+ // all pure), plus `@remotion/paths`/`@remotion/shapes` for transitions — which
92
+ // hold no cross-package state, so a per-bundle copy is harmless. NOT shipping
93
+ // `@remotion/renderer`/`@remotion/web-renderer` (export goes through the
94
+ // capture spine). Pre-built into dist/runtime + floored in .min-sizes.json.
95
+ 'remotion',
96
+ '@remotion/player',
97
+ '@remotion/media',
98
+ '@remotion/transitions',
99
+ // Transition presentations are separate subpath modules by design (Remotion
100
+ // tree-shakes to what you import). But a canvas import specifier that isn't
101
+ // in RUNTIME_PACKAGES tries to resolve against the USER's node_modules at
102
+ // request-time Bun.build — which doesn't exist on an npm/marketplace install
103
+ // — so EVERY presentation the skill teaches must be pre-bundled + importmap-
104
+ // routed. v1 ships the six core wipes ("join 4 clips + crossfade" vocabulary);
105
+ // exotic presentations (dreamy-zoom, film-burn, …) are a documented follow-up.
106
+ '@remotion/transitions/fade',
107
+ '@remotion/transitions/slide',
108
+ '@remotion/transitions/wipe',
109
+ '@remotion/transitions/flip',
110
+ '@remotion/transitions/clock-wipe',
111
+ '@remotion/transitions/none',
82
112
  ] as const;
83
113
 
84
114
  export type RuntimePackage = (typeof RUNTIME_PACKAGES)[number];
@@ -18,13 +18,13 @@ import { spawn } from 'node:child_process';
18
18
 
19
19
  import { createAcp } from './acp/index.ts';
20
20
  import { createActivity } from './activity.ts';
21
- import { createApi } from './api.ts';
21
+ import { ASSET_MAX_VIDEO_BYTES, createApi } from './api.ts';
22
22
  import { bootSelfHeal } from './boot-self-heal.ts';
23
23
  import { createCanvasListWatch } from './canvas-list-watch.ts';
24
24
  import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
25
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
26
26
  import { createCollab } from './collab/index.ts';
27
- import { createContext } from './context.ts';
27
+ import { createContext, reloadConfig } from './context.ts';
28
28
  import { createFsWatch } from './fs-watch.ts';
29
29
  import { createGitWatch } from './git/watch.ts';
30
30
  import { createHttp } from './http.ts';
@@ -124,13 +124,16 @@ const { port: BASE_PORT, explicit: PORT_EXPLICIT } = resolvePort();
124
124
 
125
125
  type BunServer = ReturnType<typeof Bun.serve<WsData, never>>;
126
126
 
127
- // Phase 23 security review (DDR-088 follow-up) — hard ceiling on a buffered
128
- // request body. Without it Bun's 128 MB default applies, so `POST /_api/asset`
129
- // would buffer up to 128 MB into RAM BEFORE api.saveAsset's 10 MB check runs
130
- // (memory amplification from the untrusted canvas origin). 16 MB covers every
131
- // legit body (10 MB asset + 1 MB annotation SVG + small JSON) with headroom and
132
- // bounds the pre-handler buffer; the authoritative per-route caps still apply.
133
- const MAX_REQUEST_BODY = 16 * 1024 * 1024;
127
+ // Phase 23 security review (DDR-088) + DDR-148 — hard ceiling on a request
128
+ // body. Bun's 128 MB default would let the untrusted canvas origin send huge
129
+ // bodies to any route. DDR-088 pinned this at 16 MB. DDR-148 raises it to the
130
+ // video cap + 8 MB headroom so `POST /_api/asset` can accept a 100 MB clip
131
+ // but that route now STREAMS the body to disk (saveAssetFromStream), so a big
132
+ // upload never lands as one ArrayBuffer in RAM. Other routes keep their small
133
+ // LOGICAL caps (annotation 1 MB, JSON) — a body over those still rejects; the
134
+ // only change is Bun accepts more bytes pre-handler (a bandwidth-bounded
135
+ // transient-buffer tradeoff on the buffering routes, noted in DDR-148).
136
+ const MAX_REQUEST_BODY = ASSET_MAX_VIDEO_BYTES + 8 * 1024 * 1024;
134
137
 
135
138
  function startServer(port: number): BunServer {
136
139
  return Bun.serve<WsData, never>({
@@ -401,6 +404,28 @@ const gitWatch = createGitWatch(ctx);
401
404
  // `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
402
405
  const canvasListWatch = createCanvasListWatch(ctx);
403
406
 
407
+ // Config hot-reload — `/design:setup-ds` (or a hand edit) rewrites
408
+ // `.design/config.json` mid-session; without a re-read the server keeps
409
+ // serving the boot snapshot and a newly added canvas group (`system`) never
410
+ // reaches /_index-data, so scaffolded DS files stay invisible even on a
411
+ // manual tree reload. On change: swap ctx.cfg in place (reloadConfig), let
412
+ // canvasListWatch's set-diff emit `canvas-list-update` for canvases the new
413
+ // groups uncover, and tell shells to refetch /_config.
414
+ // RCA: .ai/logs/rca/issue-ds-scaffold-files-not-in-filetree-stale-config.md
415
+ const CONFIG_RELOAD_DEBOUNCE_MS = 150;
416
+ let configReloadTimer: ReturnType<typeof setTimeout> | null = null;
417
+ ctx.bus.on('fs:json', (rel: string) => {
418
+ if (rel.replace(/\\/g, '/').replace(/^\/+/, '') !== 'config.json') return;
419
+ if (configReloadTimer) clearTimeout(configReloadTimer);
420
+ configReloadTimer = setTimeout(() => {
421
+ configReloadTimer = null;
422
+ if (!reloadConfig(ctx)) return;
423
+ console.log(' config.json changed — reloaded live.');
424
+ void canvasListWatch.refresh();
425
+ ctx.bus.emit('config-updated');
426
+ }, CONFIG_RELOAD_DEBOUNCE_MS);
427
+ });
428
+
404
429
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
405
430
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
406
431
  // off after fsWatch so the agent's bus subscription receives every fs event.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @file sizing-mode.ts
3
+ * @purpose Pure Fixed / Hug / Fill sizing-mode logic for the CssKnobs Size
4
+ * control (feature-element-editing-robustness Stage M1). Kept framework-
5
+ * free + in a .ts file so it's unit-testable (app.jsx is bundled JSX).
6
+ *
7
+ * Figma auto-layout parity: a per-axis Fixed / Hug / Fill mode is the single
8
+ * biggest source of layout-behaviour clarity for React/flexbox mockups.
9
+ * - Fixed = an explicit width/height value.
10
+ * - Hug = shrink to children (`fit-content`).
11
+ * - Fill = stretch to fill the parent — and the CORRECT CSS depends on the
12
+ * parent's layout: a flex child fills its MAIN axis via `flex-grow`,
13
+ * its CROSS axis via `align-self: stretch`; a block/grid child fills
14
+ * via `width/height: 100%`. So the mode must know the parent context
15
+ * (captured at selection time in Selection.parentDisplay /
16
+ * .parentFlexDirection — the shell can't reach the iframe later).
17
+ */
18
+
19
+ export type SizeAxis = 'width' | 'height';
20
+ export type SizeMode = 'fixed' | 'hug' | 'fill';
21
+
22
+ export interface ParentLayout {
23
+ display?: string;
24
+ flexDirection?: string;
25
+ }
26
+
27
+ const HUG_VALUES = new Set(['fit-content', 'max-content', 'min-content']);
28
+ const FILL_VALUES = new Set(['100%', 'fill', '-webkit-fill-available', 'stretch']);
29
+
30
+ function parentIsFlex(p: ParentLayout | undefined): boolean {
31
+ const d = (p?.display || '').trim();
32
+ return d === 'flex' || d === 'inline-flex';
33
+ }
34
+
35
+ /** True when `axis` is the flex MAIN axis of the parent (row → width, column → height). */
36
+ export function isMainAxis(axis: SizeAxis, p: ParentLayout | undefined): boolean {
37
+ const dir = (p?.flexDirection || 'row').trim();
38
+ const rowMain = dir.startsWith('row'); // row / row-reverse
39
+ return rowMain ? axis === 'width' : axis === 'height';
40
+ }
41
+
42
+ /**
43
+ * Best-effort classification of the current sizing mode for the segmented
44
+ * control's active-state highlight. Reads AUTHORED values first (what the user
45
+ * set), so a value the panel wrote round-trips; defaults to `fixed`. Detection is
46
+ * advisory — the WRITE side (`sizingModePatch`) is what must be deterministic.
47
+ */
48
+ export function sizingModeOf(
49
+ axis: SizeAxis,
50
+ authored: Record<string, string> = {},
51
+ _computed: Record<string, string> = {},
52
+ parent?: ParentLayout
53
+ ): SizeMode {
54
+ const a = (authored[axis] || '').trim().toLowerCase();
55
+ if (HUG_VALUES.has(a)) return 'hug';
56
+ if (FILL_VALUES.has(a)) return 'fill';
57
+ if (parentIsFlex(parent)) {
58
+ const main = isMainAxis(axis, parent);
59
+ if (main && Number.parseFloat(authored['flex-grow'] || '0') >= 1) return 'fill';
60
+ if (!main && (authored['align-self'] || '').trim() === 'stretch') return 'fill';
61
+ }
62
+ // A numeric authored value (px/rem/%…) or nothing authored → Fixed.
63
+ return 'fixed';
64
+ }
65
+
66
+ export interface SizingPatch {
67
+ /** Properties to write (in order), each `[prop, value]`. */
68
+ set: Array<[string, string]>;
69
+ /** Fill-specific properties to REMOVE so a prior Fill doesn't linger. */
70
+ reset: string[];
71
+ }
72
+
73
+ /**
74
+ * The deterministic CSS writes for switching `axis` to `mode`. `currentPx` seeds
75
+ * the Fixed value (the element's current rendered size). Fill is context-aware:
76
+ * a flex child fills its main axis via `flex-grow:1` + `flex-basis:0%`, its cross
77
+ * axis via `align-self:stretch`; a block/grid child via `<axis>: 100%`.
78
+ */
79
+ export function sizingModePatch(
80
+ axis: SizeAxis,
81
+ mode: SizeMode,
82
+ parent: ParentLayout | undefined,
83
+ currentPx: number
84
+ ): SizingPatch {
85
+ const flex = parentIsFlex(parent);
86
+ const main = isMainAxis(axis, parent);
87
+ // Which fill-specific props a non-fill mode must clear so the old Fill doesn't linger.
88
+ const fillProps = flex ? (main ? ['flex-grow', 'flex-basis'] : ['align-self']) : [];
89
+ if (mode === 'fixed') {
90
+ const px = Number.isFinite(currentPx) && currentPx > 0 ? Math.round(currentPx) : 0;
91
+ return { set: [[axis, `${px}px`]], reset: fillProps };
92
+ }
93
+ if (mode === 'hug') {
94
+ return { set: [[axis, 'fit-content']], reset: fillProps };
95
+ }
96
+ // fill
97
+ if (flex) {
98
+ if (main) {
99
+ return {
100
+ set: [
101
+ ['flex-grow', '1'],
102
+ ['flex-basis', '0%'],
103
+ [axis, 'auto'],
104
+ ],
105
+ reset: [],
106
+ };
107
+ }
108
+ return {
109
+ set: [
110
+ ['align-self', 'stretch'],
111
+ [axis, 'auto'],
112
+ ],
113
+ reset: [],
114
+ };
115
+ }
116
+ return { set: [[axis, '100%']], reset: [] };
117
+ }