@1agh/maude 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  3. package/apps/studio/annotations-layer.tsx +253 -3
  4. package/apps/studio/annotations-model.ts +107 -6
  5. package/apps/studio/api.ts +812 -64
  6. package/apps/studio/bin/_html-playwright.mjs +9 -1
  7. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  8. package/apps/studio/bin/_png-playwright.mjs +8 -1
  9. package/apps/studio/bin/_pw-launch.mjs +54 -0
  10. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  11. package/apps/studio/bin/_video-playwright.mjs +452 -0
  12. package/apps/studio/bin/prep.sh +8 -1
  13. package/apps/studio/canvas-edit.ts +1885 -104
  14. package/apps/studio/canvas-lib.tsx +19 -0
  15. package/apps/studio/canvas-list-watch.ts +6 -2
  16. package/apps/studio/canvas-shell.tsx +27 -0
  17. package/apps/studio/client/app.jsx +1029 -30
  18. package/apps/studio/client/github.js +7 -0
  19. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  20. package/apps/studio/client/panels/timeline-parse.js +229 -0
  21. package/apps/studio/client/panels/timeline-snap.js +55 -0
  22. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  23. package/apps/studio/config.schema.json +14 -0
  24. package/apps/studio/context-menu.tsx +1 -1
  25. package/apps/studio/context.ts +113 -1
  26. package/apps/studio/dist/client.bundle.js +88 -16
  27. package/apps/studio/dist/comment-mount.js +1 -1
  28. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  29. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  30. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  31. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  32. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  38. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  39. package/apps/studio/dist/runtime/remotion.js +42 -0
  40. package/apps/studio/dist/styles.css +1 -1
  41. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  42. package/apps/studio/exporters/_runtime.ts +69 -0
  43. package/apps/studio/exporters/html.ts +4 -3
  44. package/apps/studio/exporters/index.ts +28 -2
  45. package/apps/studio/exporters/pdf.ts +4 -3
  46. package/apps/studio/exporters/png.ts +5 -3
  47. package/apps/studio/exporters/pptx.ts +6 -4
  48. package/apps/studio/exporters/svg.ts +9 -5
  49. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  50. package/apps/studio/exporters/video-render-lib.ts +108 -0
  51. package/apps/studio/exporters/video.ts +184 -0
  52. package/apps/studio/http.ts +535 -27
  53. package/apps/studio/input-router.tsx +7 -1
  54. package/apps/studio/runtime-bundle.ts +30 -0
  55. package/apps/studio/server.ts +34 -9
  56. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  57. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  58. package/apps/studio/test/canvas-edit.test.ts +90 -0
  59. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  60. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  62. package/apps/studio/test/clip-addressing.test.ts +732 -0
  63. package/apps/studio/test/config-reload.test.ts +230 -0
  64. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  65. package/apps/studio/test/edit-persistence.test.ts +91 -0
  66. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  67. package/apps/studio/test/file-lock.test.ts +84 -0
  68. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  69. package/apps/studio/test/timeline-parse.test.ts +127 -0
  70. package/apps/studio/test/timeline-snap.test.ts +85 -0
  71. package/apps/studio/test/video-asset.test.ts +163 -0
  72. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  73. package/apps/studio/test/video-comp.test.ts +168 -0
  74. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  75. package/apps/studio/use-annotation-resize.tsx +6 -3
  76. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  77. package/apps/studio/video-comp.tsx +444 -0
  78. package/apps/studio/whats-new.json +27 -0
  79. package/apps/studio/ws.ts +5 -0
  80. package/package.json +8 -8
  81. package/plugins/design/templates/_shell.html +25 -2
  82. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -19,8 +19,16 @@ import GitPanel from './panels/GitPanel.jsx';
19
19
  import IdentityBar from './panels/IdentityBar.jsx';
20
20
  import OnboardingWizard from './panels/OnboardingWizard.jsx';
21
21
  import { ReadinessDialog } from './panels/ReadinessList.jsx';
22
+ import TimelinePanel from './panels/TimelinePanel.jsx';
23
+ import { parseCompTimeline } from './panels/timeline-parse.js';
22
24
  import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
23
- import { appIsFirstRun, isNativeApp, onUpdateReady, restartToUpdate } from './github.js';
25
+ import {
26
+ appIsFirstRun,
27
+ isNativeApp,
28
+ onUpdateReady,
29
+ restartToUpdate,
30
+ saveExport,
31
+ } from './github.js';
24
32
  import { COLLAB_TOUR } from './tour/collab-tour.js';
25
33
  import { TourOverlay } from './tour/overlay.jsx';
26
34
  import { USAGE_TOUR } from './tour/usage-tour.js';
@@ -92,6 +100,33 @@ function basename(p) {
92
100
  return p.split('/').pop();
93
101
  }
94
102
 
103
+ // DDR-150 dogfood — a transient shell toast for timeline/inline-edit op results.
104
+ // The clip ops used to console.warn their failures (a 422 "series clip can't
105
+ // move" looked like "the button does nothing"). Self-contained DOM (no React
106
+ // state), mirrors the canvas-side showCanvasToast.
107
+ function shellToast(message, ok = false) {
108
+ if (typeof document === 'undefined') return;
109
+ let el = document.getElementById('st-op-toast');
110
+ if (!el) {
111
+ el = document.createElement('div');
112
+ el.id = 'st-op-toast';
113
+ el.setAttribute('role', 'status');
114
+ el.style.cssText =
115
+ 'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
116
+ 'max-width:440px;padding:8px 14px;border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
117
+ 'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
118
+ document.body.appendChild(el);
119
+ }
120
+ el.style.background = ok ? '#1d3524' : '#3a1d1d';
121
+ el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
122
+ el.textContent = message;
123
+ el.style.opacity = '1';
124
+ clearTimeout(shellToast._t);
125
+ shellToast._t = setTimeout(() => {
126
+ el.style.opacity = '0';
127
+ }, 3200);
128
+ }
129
+
95
130
  // Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
96
131
  // Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
97
132
  // the file type stays unambiguous.
@@ -768,6 +803,11 @@ const EXPORT_CARDS = [
768
803
  { id: 'svg', label: 'SVG', sub: 'per artboard', icon: 'vector', format: 'svg' },
769
804
  { id: 'html', label: 'HTML', sub: 'self-contained', icon: 'code', format: 'html' },
770
805
  { id: 'pptx', label: 'PPTX', sub: 'slides', icon: 'presentation', format: 'pptx' },
806
+ // DDR-148 — temporal formats. Shown only when the active canvas has a
807
+ // video-comp (`temporal: true` + the hasComps gate in ExportDialog); the
808
+ // capture engine renders the artboard frame-by-frame.
809
+ { id: 'mp4', label: 'MP4', sub: 'video · H.264', icon: 'presentation', format: 'mp4', temporal: true },
810
+ { id: 'gif', label: 'GIF', sub: 'animated', icon: 'image', format: 'gif', temporal: true },
771
811
  { id: 'canva', label: 'Canva', sub: 'handoff bundle', icon: 'external', format: 'canva' },
772
812
  { id: 'zip', label: 'ZIP', sub: 'project bundle', icon: 'archive', format: 'zip' },
773
813
  { id: 'shadcn', label: 'AI handoff', sub: 'production drop', icon: 'sparkle', handoff: true },
@@ -787,6 +827,9 @@ const EXPORT_VALID_SCOPES = {
787
827
  svg: ['selection', 'artboard', 'canvas-as-separate'],
788
828
  html: ['artboard', 'canvas-as-separate'],
789
829
  pptx: ['canvas-as-separate'],
830
+ mp4: ['artboard'],
831
+ gif: ['artboard'],
832
+ webm: ['artboard'],
790
833
  canva: ['canvas-as-separate'],
791
834
  zip: ['project-raw'],
792
835
  };
@@ -796,12 +839,25 @@ const PNG_SCALES = [
796
839
  { value: 3, label: '3× (max)' },
797
840
  ];
798
841
 
799
- function ExportDialog({ mode, initialScope, activePath, onClose }) {
842
+ function ExportDialog({
843
+ mode,
844
+ initialScope,
845
+ activePath,
846
+ hasComps = false,
847
+ activeArtboardId = null,
848
+ selection = null,
849
+ onClose,
850
+ }) {
800
851
  const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
801
852
  const [scope, setScope] = useState(
802
853
  initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
803
854
  );
804
855
  const [scale, setScale] = useState(2);
856
+ // DDR-148 addendum — mp4/webm of a registered video-comp render through
857
+ // renderMediaOnWeb, which produces real audio (Remotion owns the
858
+ // TransitionSeries/volume-closure timeline). gif has no audio track at all
859
+ // (format limitation, not a toggle), so this only applies to mp4/webm.
860
+ const [audio, setAudio] = useState(true);
805
861
  const [busy, setBusy] = useState(false);
806
862
  const [status, setStatus] = useState(null); // { ok, msg }
807
863
  const [recent, setRecent] = useState([]);
@@ -847,7 +903,18 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
847
903
  }
848
904
  setBusy(true);
849
905
  setStatus(null);
850
- const options = card.format === 'png' ? { scale } : {};
906
+ // `scale` drives PNG size AND video resolution (deviceScaleFactor encoder
907
+ // dims); temporal formats were previously fixed at the tiny native size.
908
+ const options = card.format === 'png' || card.temporal ? { scale } : {};
909
+ // Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
910
+ // format, so the checkbox never renders for it (see hasAudioToggle below).
911
+ if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
912
+ // Scope targeting hints (resolveScope reads these): `artboardId` makes
913
+ // "Active artboard" export the right screen instead of `:first-of-type`;
914
+ // `selection` makes "Current selection" export the selected element. Mirrors
915
+ // the in-canvas dialog's captureScopeHints.
916
+ if (activeArtboardId) options.artboardId = activeArtboardId;
917
+ if (selection?.selector) options.selection = selection;
851
918
  try {
852
919
  const r = await fetch('/_api/export', {
853
920
  method: 'POST',
@@ -863,16 +930,30 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
863
930
  const fn = /filename="([^"]+)"/.exec(disp);
864
931
  const filename = (fn && fn[1]) || `export.${card.format}`;
865
932
  const blob = await r.blob();
866
- const url = URL.createObjectURL(blob);
867
- const a = document.createElement('a');
868
- a.href = url;
869
- a.download = filename;
870
- document.body.appendChild(a);
871
- a.click();
872
- a.remove();
873
- URL.revokeObjectURL(url);
874
- setStatus({ ok: true, msg: `Exported ${filename}` });
875
- loadRecent();
933
+ if (isNativeApp()) {
934
+ // Native: WKWebView swallows the `<a download>` blob (the file lands in
935
+ // an unknown default with no prompt). Route through a real OS save dialog
936
+ // so the export OFFERS a location. Cancel → soft no-op.
937
+ const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
938
+ const savedPath = await saveExport(filename, bytes);
939
+ if (savedPath) {
940
+ setStatus({ ok: true, msg: `Saved to ${savedPath}` });
941
+ loadRecent();
942
+ } else {
943
+ setStatus({ ok: true, msg: 'Save cancelled' });
944
+ }
945
+ } else {
946
+ const url = URL.createObjectURL(blob);
947
+ const a = document.createElement('a');
948
+ a.href = url;
949
+ a.download = filename;
950
+ document.body.appendChild(a);
951
+ a.click();
952
+ a.remove();
953
+ URL.revokeObjectURL(url);
954
+ setStatus({ ok: true, msg: `Exported ${filename}` });
955
+ loadRecent();
956
+ }
876
957
  } catch (err) {
877
958
  setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
878
959
  }
@@ -901,7 +982,7 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
901
982
  : 'Format'}
902
983
  </div>
903
984
  <div className="st-fmt-grid">
904
- {EXPORT_CARDS.map((c) => (
985
+ {EXPORT_CARDS.filter((c) => !c.temporal || hasComps).map((c) => (
905
986
  <button
906
987
  type="button"
907
988
  key={c.id}
@@ -936,10 +1017,10 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
936
1017
  </select>
937
1018
  </div>
938
1019
  )}
939
- {!card.handoff && card.format === 'png' && (
1020
+ {!card.handoff && (card.format === 'png' || card.temporal) && (
940
1021
  <div className="st-dialog-row">
941
1022
  <label className="st-dialog-lbl" htmlFor="st-export-size">
942
- Size
1023
+ {card.temporal ? 'Resolution' : 'Size'}
943
1024
  </label>
944
1025
  <select
945
1026
  id="st-export-size"
@@ -960,6 +1041,28 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
960
1041
  Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
961
1042
  </div>
962
1043
  )}
1044
+ {!card.handoff && card.temporal && (
1045
+ <div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
1046
+ {scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
1047
+ </div>
1048
+ )}
1049
+ {!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
1050
+ <div className="st-dialog-row">
1051
+ <label
1052
+ className="st-dialog-lbl"
1053
+ htmlFor="st-export-audio"
1054
+ style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
1055
+ >
1056
+ <input
1057
+ id="st-export-audio"
1058
+ type="checkbox"
1059
+ checked={audio}
1060
+ onChange={(e) => setAudio(e.target.checked)}
1061
+ />
1062
+ Export with audio
1063
+ </label>
1064
+ </div>
1065
+ )}
963
1066
  {card.handoff && (
964
1067
  <div className="callout callout--info" style={{ fontSize: 12 }}>
965
1068
  Hands the active canvas off to production. Copies{' '}
@@ -2340,6 +2443,9 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
2340
2443
  items={[
2341
2444
  // Bare N — the browser reserves ⌘N (New Window) and never delivers it.
2342
2445
  { id: 'new', label: 'New canvas…', shortcut: 'N' },
2446
+ // DDR-150 P4 Task 12 — one-click "udělej z toho video" from the clips
2447
+ // dropped as reference chips on the active canvas.
2448
+ { id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
2343
2449
  { id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
2344
2450
  { id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
2345
2451
  { sep: true },
@@ -2406,11 +2512,15 @@ function Menubar({
2406
2512
  inspectorTab,
2407
2513
  onToggleInspector,
2408
2514
  onOpenLayers,
2515
+ timelineOpen,
2516
+ onToggleTimeline,
2517
+ hasComps = false,
2409
2518
  assistantOpen,
2410
2519
  onToggleAssistant,
2411
2520
  assistantBusy,
2412
2521
  assistantUnseen,
2413
2522
  onNewCanvas,
2523
+ onAssembleVideo,
2414
2524
  onOpenExport,
2415
2525
  onReload,
2416
2526
  onCloseCanvas,
@@ -2464,6 +2574,15 @@ function Menubar({
2464
2574
  checked: inspectorOpen,
2465
2575
  disabled: false,
2466
2576
  },
2577
+ // DDR-148 — Timeline (video-comp scrub). Phase-tag hints when the active
2578
+ // canvas actually has a comp; the panel itself shows an empty state otherwise.
2579
+ {
2580
+ id: 'timeline',
2581
+ label: 'Timeline',
2582
+ phase: hasComps ? 'video' : undefined,
2583
+ checked: timelineOpen,
2584
+ disabled: false,
2585
+ },
2467
2586
  // Phase 31 (DDR-123) — native-only ACP chat sidepanel.
2468
2587
  ...(isNativeApp()
2469
2588
  ? [
@@ -2598,6 +2717,7 @@ function Menubar({
2598
2717
  hasCanvas={!!activePath}
2599
2718
  onAction={(id) => {
2600
2719
  if (id === 'new') onNewCanvas?.();
2720
+ else if (id === 'assemble') onAssembleVideo?.();
2601
2721
  else if (id === 'export') onOpenExport?.('export');
2602
2722
  else if (id === 'handoff') onOpenExport?.('handoff');
2603
2723
  else if (id === 'reload') onReload?.();
@@ -2628,6 +2748,7 @@ function Menubar({
2628
2748
  else if (id === 'hidden') onToggleShowHidden();
2629
2749
  else if (id === 'annotate') onToggleAnnotations();
2630
2750
  else if (id === 'inspector') onToggleInspector();
2751
+ else if (id === 'timeline') onToggleTimeline?.();
2631
2752
  else if (id === 'assistant') onToggleAssistant?.();
2632
2753
  else if (id === 'layers') onOpenLayers?.();
2633
2754
  else if (id === 'minimap') onToggleMinimap?.();
@@ -5993,15 +6114,15 @@ function App() {
5993
6114
  const cap = setTimeout(() => setLoadingPath(null), 15000);
5994
6115
  return () => clearTimeout(cap);
5995
6116
  }, [loadingPath]);
5996
- // Loaded once at boot from /_config informs canvasUrl() so TSX iframes
5997
- // can pass the right ?designRel + ?tokens query to the canvas mount shell.
6117
+ // Loaded at boot from /_config and re-fetched on the server's
6118
+ // `config-updated` push (config.json hot-reload /design:setup-ds rewrites
6119
+ // it mid-session) — informs canvasUrl() so TSX iframes can pass the right
6120
+ // ?designRel + ?tokens query to the canvas mount shell.
5998
6121
  const [cfg, setCfg] = useState({ designRel: '.design' });
5999
- useEffect(() => {
6000
- let cancelled = false;
6122
+ const loadServerConfig = useCallback(() => {
6001
6123
  fetch('/_config')
6002
6124
  .then((r) => r.json())
6003
6125
  .then((data) => {
6004
- if (cancelled) return;
6005
6126
  const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
6006
6127
  // Functional merge — the `/_config` and `/_index-data` fetches race, and
6007
6128
  // the latter contributes `canvasDesignSystems` (DDR-093). A full-replace
@@ -6023,10 +6144,10 @@ function App() {
6023
6144
  }));
6024
6145
  })
6025
6146
  .catch(() => {});
6026
- return () => {
6027
- cancelled = true;
6028
- };
6029
6147
  }, []);
6148
+ useEffect(() => {
6149
+ loadServerConfig();
6150
+ }, [loadServerConfig]);
6030
6151
  // Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
6031
6152
  // broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
6032
6153
  // tab that connects after boot would otherwise miss it. {linked:false} (solo)
@@ -6079,6 +6200,46 @@ function App() {
6079
6200
  // The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
6080
6201
  const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
6081
6202
  const [inspectorOpen, setInspectorOpen] = useState(false);
6203
+ // DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
6204
+ // is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
6205
+ // its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
6206
+ const [timelineOpen, setTimelineOpen] = useState(false);
6207
+ const [activeComps, setActiveComps] = useState([]);
6208
+ const [timelineFrame, setTimelineFrame] = useState(0);
6209
+ const [timelinePlaying, setTimelinePlaying] = useState(false);
6210
+ const [timelineLoop, setTimelineLoop] = useState(true);
6211
+ const [timelineMuted, setTimelineMuted] = useState(false);
6212
+ const [timelineVolume, setTimelineVolume] = useState(1);
6213
+ const [timelineHeight, setTimelineHeight] = useState(216);
6214
+ // The artboard nearest the viewport centre (reported by canvas-lib on pan) —
6215
+ // the Timeline follows THIS, so it redraws as you move across the canvas.
6216
+ const [canvasActiveArtboard, setCanvasActiveArtboard] = useState(null);
6217
+ // DDR-148 — parsed sequence/keyframe rows for the Timeline (from raw .tsx).
6218
+ const [timelineSequences, setTimelineSequences] = useState([]);
6219
+ const [timelineAudio, setTimelineAudio] = useState([]);
6220
+ const [timelineTotal, setTimelineTotal] = useState(0);
6221
+ // On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
6222
+ // + reports its total). Match that total back to the announced comp id so the
6223
+ // transport/scrub targets ONLY that comp — not every artboard on the canvas.
6224
+ const timelineCompId = useMemo(() => {
6225
+ if (!activeComps.length) return null;
6226
+ const match = activeComps.find((c) => c.durationInFrames === timelineTotal);
6227
+ return (match ?? activeComps[0]).id;
6228
+ }, [activeComps, timelineTotal]);
6229
+ const timelineCompIdRef = useRef(null);
6230
+ useEffect(() => {
6231
+ timelineCompIdRef.current = timelineCompId;
6232
+ }, [timelineCompId]);
6233
+ // The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
6234
+ // /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
6235
+ // rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
6236
+ // never matches a DCArtboard id and made the enumerator fall back to the wrong
6237
+ // comp on a multi-comp canvas (the showreel badge/replace/op mis-scope).
6238
+ const [timelineArtboardId, setTimelineArtboardId] = useState(null);
6239
+ const timelineArtboardIdRef = useRef(null);
6240
+ useEffect(() => {
6241
+ timelineArtboardIdRef.current = timelineArtboardId;
6242
+ }, [timelineArtboardId]);
6082
6243
  // Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
6083
6244
  const [assistantOpen, setAssistantOpen] = useState(false);
6084
6245
  const [assistantBusy, setAssistantBusy] = useState(false);
@@ -6128,6 +6289,8 @@ function App() {
6128
6289
  setInspectorOpen(which === 'inspector');
6129
6290
  setCommentsPanelOpen(which === 'comments');
6130
6291
  setAssistantOpen(which === 'assistant');
6292
+ // NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
6293
+ // mutual-exclusion — it stays open when a right panel opens.
6131
6294
  }, []);
6132
6295
  // Functional updates so this is stale-closure-safe inside the keydown /
6133
6296
  // postMessage listeners; opening always clears the sibling panels.
@@ -6170,6 +6333,9 @@ function App() {
6170
6333
  });
6171
6334
  }
6172
6335
  }, []);
6336
+ // DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
6337
+ // toggles on its own and coexists with Inspector/Changes/Comments/Chat.
6338
+ const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
6173
6339
  const whatsNew = useWhatsNew(MDCC_VERSION);
6174
6340
  // Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
6175
6341
  // "welcome" project on first launch; we ask it whether this is a first run and, if
@@ -6288,6 +6454,269 @@ function App() {
6288
6454
  [activePath]
6289
6455
  );
6290
6456
 
6457
+ // DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
6458
+ // with no drop target used to make the browser NAVIGATE AWAY to the file
6459
+ // (file:// replaces the app). Document-level guard: always cancel the default
6460
+ // for file drags; the TimelinePanel's own onDrop (bubbling before this) still
6461
+ // receives the drop first — this only blocks the browser fallback.
6462
+ useEffect(() => {
6463
+ const isFileDrag = (e) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
6464
+ const onDocDragOver = (e) => {
6465
+ if (isFileDrag(e)) e.preventDefault();
6466
+ };
6467
+ const onDocDrop = (e) => {
6468
+ if (isFileDrag(e)) e.preventDefault();
6469
+ };
6470
+ document.addEventListener('dragover', onDocDragOver);
6471
+ document.addEventListener('drop', onDocDrop);
6472
+ return () => {
6473
+ document.removeEventListener('dragover', onDocDragOver);
6474
+ document.removeEventListener('drop', onDocDrop);
6475
+ };
6476
+ }, []);
6477
+
6478
+ // DDR-150 P3 Task 9 — timeline keyboard shortcuts. Read live state through a
6479
+ // ref so the listener attaches once. Gated on: timeline open + a comp active +
6480
+ // focus NOT in a text field. Space doesn't steal the canvas PAN chord because
6481
+ // that keydown fires inside the focused canvas iframe, never reaching this
6482
+ // top-document listener. Space = play/pause · ←/→ = ±1 frame (Shift = ±1s) ·
6483
+ // Home/End = start/end · ,/. = prev/next keyframe boundary.
6484
+ const tlKeyRef = useRef({});
6485
+ tlKeyRef.current = {
6486
+ open: timelineOpen,
6487
+ comps: activeComps,
6488
+ frame: timelineFrame,
6489
+ total: timelineTotal,
6490
+ playing: timelinePlaying,
6491
+ compId: timelineCompId,
6492
+ muted: timelineMuted,
6493
+ loop: timelineLoop,
6494
+ sequences: timelineSequences,
6495
+ post: postToActiveCanvas,
6496
+ canvas: activePath,
6497
+ };
6498
+
6499
+ // DDR-150 dogfood #1 — Timeline undo/redo. Every successful clip op (move /
6500
+ // trim / remove / insert / z-reorder / replace-src) returns a server `seq`
6501
+ // registered in the whole-file undo log; Cmd+Z / Shift+Cmd+Z replay it via
6502
+ // /_api/reorder-revert (guarded swap — 409s honestly if the canvas diverged).
6503
+ const tlUndoRef = useRef({ undo: [], redo: [] });
6504
+ const pushTlUndo = useCallback((canvas, seq, label) => {
6505
+ if (typeof seq !== 'number') return;
6506
+ tlUndoRef.current.undo.push({ canvas, seq, label });
6507
+ tlUndoRef.current.redo = []; // a new edit invalidates the redo branch
6508
+ if (tlUndoRef.current.undo.length > 50) tlUndoRef.current.undo.shift();
6509
+ }, []);
6510
+ const tlUndoRedo = useCallback((dir) => {
6511
+ const s = tlUndoRef.current;
6512
+ const src = dir === 'undo' ? s.undo : s.redo;
6513
+ const dst = dir === 'undo' ? s.redo : s.undo;
6514
+ const canvas = tlKeyRef.current.canvas;
6515
+ // Last entry for THIS canvas (stacks are global; ops are per-canvas).
6516
+ let idx = -1;
6517
+ for (let i = src.length - 1; i >= 0; i--) {
6518
+ if (src[i].canvas === canvas) {
6519
+ idx = i;
6520
+ break;
6521
+ }
6522
+ }
6523
+ if (idx < 0) {
6524
+ shellToast(dir === 'undo' ? 'Nothing to undo on this timeline.' : 'Nothing to redo.');
6525
+ return;
6526
+ }
6527
+ const entry = src[idx];
6528
+ fetch('/_api/reorder-revert', {
6529
+ method: 'POST',
6530
+ headers: { 'content-type': 'application/json' },
6531
+ body: JSON.stringify({ canvas: entry.canvas, seq: entry.seq, dir }),
6532
+ })
6533
+ .then((r) => r.json().catch(() => ({})))
6534
+ .then((j) => {
6535
+ src.splice(idx, 1);
6536
+ if (j?.ok) {
6537
+ dst.push(entry);
6538
+ shellToast(`${dir === 'undo' ? 'Undid' : 'Redid'}: ${entry.label}`, true);
6539
+ } else {
6540
+ // 409 canvas diverged / 404 server restarted — entry is dead, drop it.
6541
+ shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} skipped: ${j?.error || 'failed'}`);
6542
+ }
6543
+ })
6544
+ .catch(() => shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} failed: network error`));
6545
+ }, []);
6546
+ useEffect(() => {
6547
+ const onKey = (e) => {
6548
+ const s = tlKeyRef.current;
6549
+ if (!s.open || !s.comps?.length) return;
6550
+ const t = e.target;
6551
+ const tag = t?.tagName;
6552
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
6553
+ const fps = s.comps[0]?.fps || 30;
6554
+ const total = Math.max(1, s.total);
6555
+ const doSeek = (f) => {
6556
+ const nf = Math.max(0, Math.min(total - 1, Math.round(f)));
6557
+ setTimelineFrame(nf);
6558
+ setTimelinePlaying(false);
6559
+ s.post({ dgn: 'timeline-seek', frame: nf, id: s.compId });
6560
+ };
6561
+ const snapFrames = () => {
6562
+ const pts = new Set([0, total - 1]);
6563
+ for (const seq of s.sequences || []) {
6564
+ pts.add(seq.from);
6565
+ for (const kf of seq.keyframes || []) {
6566
+ pts.add(kf.from);
6567
+ pts.add(kf.to);
6568
+ }
6569
+ }
6570
+ return [...pts].filter((n) => n >= 0 && n < total).sort((a, b) => a - b);
6571
+ };
6572
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
6573
+ // Cmd+Z / Shift+Cmd+Z — undo/redo the last timeline clip op. Only when
6574
+ // the shell (not the canvas iframe) has focus + the timeline is active,
6575
+ // so the canvas's own annotation undo is untouched.
6576
+ e.preventDefault();
6577
+ tlUndoRedo(e.shiftKey ? 'redo' : 'undo');
6578
+ return;
6579
+ }
6580
+ if (e.key === ' ' || e.code === 'Space') {
6581
+ e.preventDefault();
6582
+ if (s.playing) {
6583
+ setTimelinePlaying(false);
6584
+ s.post({ dgn: 'timeline-pause', id: s.compId });
6585
+ } else {
6586
+ setTimelinePlaying(true);
6587
+ s.post({ dgn: 'timeline-mute', muted: s.muted, id: s.compId });
6588
+ s.post({ dgn: 'timeline-loop', loop: s.loop, id: s.compId });
6589
+ s.post({ dgn: 'timeline-play', id: s.compId });
6590
+ }
6591
+ } else if (e.key === 'ArrowRight') {
6592
+ e.preventDefault();
6593
+ doSeek(s.frame + (e.shiftKey ? fps : 1));
6594
+ } else if (e.key === 'ArrowLeft') {
6595
+ e.preventDefault();
6596
+ doSeek(s.frame - (e.shiftKey ? fps : 1));
6597
+ } else if (e.key === 'Home') {
6598
+ e.preventDefault();
6599
+ doSeek(0);
6600
+ } else if (e.key === 'End') {
6601
+ e.preventDefault();
6602
+ doSeek(total - 1);
6603
+ } else if (e.key === '.') {
6604
+ e.preventDefault();
6605
+ const next = snapFrames().find((n) => n > s.frame);
6606
+ if (next != null) doSeek(next);
6607
+ } else if (e.key === ',') {
6608
+ e.preventDefault();
6609
+ const prev = snapFrames()
6610
+ .reverse()
6611
+ .find((n) => n < s.frame);
6612
+ if (prev != null) doSeek(prev);
6613
+ }
6614
+ };
6615
+ window.addEventListener('keydown', onKey);
6616
+ return () => window.removeEventListener('keydown', onKey);
6617
+ }, []);
6618
+
6619
+ // DDR-148 — reset the Timeline's comp meta when the active canvas changes (a
6620
+ // non-comp canvas fires no announce, so stale comps would linger), then ask
6621
+ // the (possibly already-mounted) active canvas to re-announce. video-comp.tsx
6622
+ // answers `timeline-request-comps` with a fresh `timeline-comps`.
6623
+ useEffect(() => {
6624
+ setActiveComps([]);
6625
+ setTimelineFrame(0);
6626
+ setTimelinePlaying(false);
6627
+ const t = setTimeout(() => postToActiveCanvas({ dgn: 'timeline-request-comps' }), 60);
6628
+ return () => clearTimeout(t);
6629
+ }, [activePath, postToActiveCanvas]);
6630
+
6631
+ // DDR-148 — fetch the active canvas's raw source when the Timeline is open.
6632
+ // Re-runs when a comp is (re)announced, so it refreshes after a canvas edit.
6633
+ const [timelineSource, setTimelineSource] = useState('');
6634
+ // DDR-150 dogfood — authoritative per-clip media from the enumerator (handles
6635
+ // wrapper components + array-fed src the regex parser can't see), merged into
6636
+ // the rows for the kind badge + replace addressing.
6637
+ const [timelineClipMedia, setTimelineClipMedia] = useState([]);
6638
+ useEffect(() => {
6639
+ if (!timelineOpen || activeComps.length === 0 || !activePath || activePath === SYSTEM_TAB) {
6640
+ setTimelineSource('');
6641
+ setTimelineClipMedia([]);
6642
+ return undefined;
6643
+ }
6644
+ let alive = true;
6645
+ fetch(`/_api/canvas-source?file=${encodeURIComponent(activePath)}`)
6646
+ .then((r) => (r.ok ? r.json() : null))
6647
+ .then((j) => {
6648
+ if (alive && j?.ok && typeof j.source === 'string') setTimelineSource(j.source);
6649
+ })
6650
+ .catch(() => {});
6651
+ return () => {
6652
+ alive = false;
6653
+ };
6654
+ }, [timelineOpen, activeComps, activePath]);
6655
+
6656
+ // Authoritative per-clip media (comp-clips) — a SEPARATE effect keyed on the
6657
+ // RESOLVED artboard id, which the parser only knows AFTER timelineSource
6658
+ // arrives. Fetching it here (not in the source effect) means it re-runs once
6659
+ // the parser scopes to the right comp — so a multi-comp canvas (the showreel:
6660
+ // Movie + Reel) overlays the CORRECT comp's media, not the enumerator's own
6661
+ // fallback pick.
6662
+ useEffect(() => {
6663
+ if (!timelineOpen || !activePath || activePath === SYSTEM_TAB || !timelineSource) {
6664
+ setTimelineClipMedia([]);
6665
+ return undefined;
6666
+ }
6667
+ let alive = true;
6668
+ const artboardId = timelineArtboardId || undefined;
6669
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
6670
+ fetch(ccUrl)
6671
+ .then((r) => (r.ok ? r.json() : null))
6672
+ .then((cc) => {
6673
+ if (!alive) return;
6674
+ const seqs =
6675
+ cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
6676
+ setTimelineClipMedia(seqs);
6677
+ })
6678
+ .catch(() => {});
6679
+ return () => {
6680
+ alive = false;
6681
+ };
6682
+ }, [timelineOpen, activePath, timelineSource, timelineArtboardId]);
6683
+
6684
+ // Parse (cheap) — re-runs on source change AND on selection change, so the
6685
+ // Timeline REDRAWS to whichever artboard the user just selected/moved to.
6686
+ useEffect(() => {
6687
+ if (!timelineSource) {
6688
+ setTimelineSequences([]);
6689
+ setTimelineAudio([]);
6690
+ setTimelineTotal(0);
6691
+ return;
6692
+ }
6693
+ const total = activeComps[0]?.durationInFrames || 0;
6694
+ const artboard = canvasActiveArtboard ?? selected?.artboardId ?? null;
6695
+ const parsed = parseCompTimeline(timelineSource, total, artboard);
6696
+ setTimelineArtboardId(parsed.artboardId ?? artboard ?? null);
6697
+ // Overlay the enumerator's authoritative media (kind badge + replace target)
6698
+ // onto each row by sequence index — it sees wrapper-component + array-fed
6699
+ // media the row parser can't (the showreel <ClipShot clip={CLIPS[i]}/> case).
6700
+ const merged = parsed.sequences.map((s, i) => {
6701
+ const cm = timelineClipMedia[i];
6702
+ if (!cm) return s;
6703
+ return {
6704
+ ...s,
6705
+ mediaTag: cm.mediaTag ?? s.mediaTag,
6706
+ mediaSrc: cm.mediaSrc ?? s.mediaSrc,
6707
+ // Replaceable when the enumerator found an addressable media target:
6708
+ // a literal-src element (mediaCdId) or an array-fed src (mediaArrayRef).
6709
+ replaceable: !!(cm.mediaCdId || cm.mediaArrayRef),
6710
+ // The clip's stacked layers (mp4 background + title/…) for expandable rows.
6711
+ layers: Array.isArray(cm.layers) ? cm.layers : [],
6712
+ hidden: !!cm.hidden,
6713
+ };
6714
+ });
6715
+ setTimelineSequences(merged);
6716
+ setTimelineAudio(parsed.audio || []);
6717
+ setTimelineTotal(parsed.total);
6718
+ }, [timelineSource, timelineClipMedia, selected, activeComps, canvasActiveArtboard]);
6719
+
6291
6720
  const toggleAnnotations = useCallback(() => {
6292
6721
  setAnnotationsVisible((v) => {
6293
6722
  const next = !v;
@@ -6563,6 +6992,11 @@ function App() {
6563
6992
  // the branch-scoped tree so other open tabs reflect it without a
6564
6993
  // reload. Cross-machine peers get a new canvas via git "Get latest".
6565
6994
  loadTree();
6995
+ } else if (m.type === 'config-updated') {
6996
+ // Server hot-reloaded .design/config.json (/design:setup-ds rewrote
6997
+ // it) — refetch /_config so designSystems / tokensCssRel / groups
6998
+ // match. The tree refresh arrives separately via canvas-list-update.
6999
+ loadServerConfig();
6566
7000
  } else if (m.type === 'acp-focus') {
6567
7001
  // Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
6568
7002
  // surface the native ACP chat sidepanel. Native-only (the panel
@@ -6590,9 +7024,9 @@ function App() {
6590
7024
  }
6591
7025
  connect();
6592
7026
  return () => wsRef.current && wsRef.current.close();
6593
- // loadTree is a stable useCallback([]); listed so the canvas-list-update
6594
- // handler always calls the live reference.
6595
- }, [loadTree]);
7027
+ // loadTree + loadServerConfig are stable useCallback([])s; listed so the
7028
+ // canvas-list-update / config-updated handlers always call the live refs.
7029
+ }, [loadTree, loadServerConfig]);
6596
7030
 
6597
7031
  function wsSend(obj) {
6598
7032
  const ws = wsRef.current;
@@ -6878,6 +7312,139 @@ function App() {
6878
7312
  [loadTree, openTab]
6879
7313
  );
6880
7314
 
7315
+ // DDR-150 P4 Task 12 — one-click "udělej z toho video". Reads the ACTIVE
7316
+ // canvas's annotation sidecar (main-origin — no cross-origin round-trip),
7317
+ // gathers every dropped media-reference chip's src in document order, and POSTs
7318
+ // an assembled video-comp (kind: 'video-comp') → opens the new, immediately
7319
+ // hand-editable comp. Durations default server-side to fps*3 (the user trims on
7320
+ // the Timeline); client-side <video>.duration probing is a documented tightening.
7321
+ const assembleVideo = useCallback(async () => {
7322
+ if (!activePath || activePath === SYSTEM_TAB) return;
7323
+ try {
7324
+ const ar = await fetch(`/_api/annotations?file=${encodeURIComponent(activePath)}`);
7325
+ const svg = ar.ok ? await ar.text() : '';
7326
+ const clips = [];
7327
+ if (svg) {
7328
+ const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
7329
+ for (const g of doc.querySelectorAll('[data-tool="mediaref"]')) {
7330
+ const src = g.getAttribute('data-src');
7331
+ if (!src) continue;
7332
+ clips.push({ src, mediaKind: g.getAttribute('data-media-kind') === 'audio' ? 'audio' : 'video' });
7333
+ }
7334
+ }
7335
+ if (clips.length === 0) {
7336
+ window.alert('Drop video/audio clips on the canvas first, then assemble them into a video.');
7337
+ return;
7338
+ }
7339
+ const baseName = displayName(basename(activePath)).replace(/\.tsx$/i, '');
7340
+ const name = `${baseName} Video`;
7341
+ const r = await fetch('/_api/canvas', {
7342
+ method: 'POST',
7343
+ headers: { 'Content-Type': 'application/json' },
7344
+ body: JSON.stringify({ name, kind: 'video-comp', clips }),
7345
+ });
7346
+ const j = await r.json().catch(() => ({}));
7347
+ if (!r.ok || !j.ok) {
7348
+ window.alert(`Assemble failed: ${j.error || `error ${r.status}`}`);
7349
+ return;
7350
+ }
7351
+ await loadTree();
7352
+ openTab(j.file);
7353
+ } catch (e) {
7354
+ window.alert(`Assemble failed: ${e instanceof Error ? e.message : 'network error'}`);
7355
+ }
7356
+ }, [activePath, loadTree, openTab]);
7357
+
7358
+ // DDR-150 dogfood #5 — shared replace-media flow, PICKER-FIRST. The <input
7359
+ // type=file> is created + clicked SYNCHRONOUSLY inside the caller's click
7360
+ // gesture (browsers revoke transient user-activation after any await, which is
7361
+ // why the old fetch-then-click never opened the dialog). The comp-clips lookup
7362
+ // + upload + src patch all happen in the change handler, where no activation
7363
+ // is needed. `resolveCdId(cc)` maps the fresh enumerator payload to the target
7364
+ // media target (sequence clip via cd-id, audio bed via cd-id, or a showreel
7365
+ // array-fed src via mediaArrayRef → /_api/edit-array-src). `resolveTarget(cc)`
7366
+ // returns { cdId } | { arrayRef } | null.
7367
+ const replaceMediaViaPicker = useCallback(
7368
+ ({ accept, resolveTarget }) => {
7369
+ const canvas = activePath;
7370
+ const artboardId = timelineArtboardId || undefined;
7371
+ const input = document.createElement('input');
7372
+ input.type = 'file';
7373
+ input.accept = accept;
7374
+ // OFF-SCREEN, not display:none — the native desktop app (Tauri WKWebView)
7375
+ // will NOT present the file panel for a `display:none` input (it must be
7376
+ // laid out). Off-screen + transparent works in both WKWebView and browsers
7377
+ // (dogfood: "replace ... nefunguje ale v desktop app"). Guard against a
7378
+ // cancelled dialog leaking the node with a one-shot focus cleanup.
7379
+ input.style.cssText =
7380
+ 'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
7381
+ document.body.appendChild(input);
7382
+ const cleanup = () => {
7383
+ if (input.isConnected) input.remove();
7384
+ };
7385
+ // If the user cancels the panel, `change` never fires — reclaim the node
7386
+ // when focus returns to the window (fires on both pick and cancel).
7387
+ window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
7388
+ input.addEventListener('change', () => {
7389
+ const file = input.files?.[0];
7390
+ cleanup();
7391
+ if (!file) return;
7392
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
7393
+ fetch(ccUrl)
7394
+ .then((r) => r.json().catch(() => ({})))
7395
+ .then((cc) => {
7396
+ const target = resolveTarget(cc);
7397
+ if (!target) {
7398
+ shellToast('No replaceable media here (its src is computed) — edit via chat.');
7399
+ return null;
7400
+ }
7401
+ return fetch('/_api/asset', {
7402
+ method: 'POST',
7403
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
7404
+ body: file,
7405
+ })
7406
+ .then((r) => r.json().catch(() => ({})))
7407
+ .then((up) => {
7408
+ if (!up?.path) {
7409
+ shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
7410
+ return null;
7411
+ }
7412
+ if (target.arrayRef) {
7413
+ // Showreel pattern: the src lives in an array literal.
7414
+ return fetch('/_api/edit-array-src', {
7415
+ method: 'POST',
7416
+ headers: { 'content-type': 'application/json' },
7417
+ body: JSON.stringify({
7418
+ canvas,
7419
+ arrayName: target.arrayRef.arrayName,
7420
+ index: target.arrayRef.index,
7421
+ field: target.arrayRef.field,
7422
+ value: up.path,
7423
+ }),
7424
+ });
7425
+ }
7426
+ return fetch('/_api/edit-attr', {
7427
+ method: 'POST',
7428
+ headers: { 'content-type': 'application/json' },
7429
+ body: JSON.stringify({ canvas, id: target.cdId, attr: 'src', value: up.path }),
7430
+ });
7431
+ });
7432
+ })
7433
+ .then((r) => (r ? r.json() : null))
7434
+ .then((j) => {
7435
+ if (j && !j.ok) shellToast(`Replace refused: ${j.error || 'failed'}`);
7436
+ else if (j && j.ok) {
7437
+ shellToast('Media replaced.', true);
7438
+ if (j.seq != null) pushTlUndo(canvas, j.seq, 'replace media');
7439
+ }
7440
+ })
7441
+ .catch(() => shellToast('Replace failed: network error'));
7442
+ });
7443
+ input.click();
7444
+ },
7445
+ [activePath, timelineArtboardId, pushTlUndo]
7446
+ );
7447
+
6881
7448
  // Phase 22 — soft-delete a canvas from the file tree. Confirms (destructive),
6882
7449
  // DELETEs to the main-origin-only endpoint, refreshes the tree, and resets the
6883
7450
  // active tab if the deleted canvas was open. The server moves the whole sidecar
@@ -7020,7 +7587,18 @@ function App() {
7020
7587
  // Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
7021
7588
  // the main-origin-only /_api/edit-text → editText writes the escaped
7022
7589
  // JSXText to source; the file-watcher HMR reload then shows the new text.
7023
- // A refusal (mixed/expression content) is logged, not fatal.
7590
+ // DDR-150 P1: a refusal is NO LONGER silent — post `edit-reverted` back
7591
+ // to the canvas, which reverts the optimistic contenteditable text AND
7592
+ // toasts the reason, so a failed edit can't silently vanish on the next
7593
+ // reload ("my text edit didn't stick"). The user-facing toast lives
7594
+ // canvas-side (showCanvasToast) because App has no in-scope status
7595
+ // surface — `setStatus` belongs to ExportDialog, not App.
7596
+ const editSource = e.source;
7597
+ const revert = (reason) => {
7598
+ try {
7599
+ editSource?.postMessage({ dgn: 'edit-reverted', op: 'text', id: m.id, reason }, '*');
7600
+ } catch {}
7601
+ };
7024
7602
  fetch('/_api/edit-text', {
7025
7603
  method: 'POST',
7026
7604
  headers: { 'content-type': 'application/json' },
@@ -7028,9 +7606,9 @@ function App() {
7028
7606
  })
7029
7607
  .then((r) => r.json().catch(() => ({})))
7030
7608
  .then((j) => {
7031
- if (!j.ok) console.warn('[edit-text]', j.error || 'failed');
7609
+ if (!j.ok) revert(j.error || "this element can't be edited inline");
7032
7610
  })
7033
- .catch(() => {});
7611
+ .catch(() => revert('network error'));
7034
7612
  } else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
7035
7613
  // Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
7036
7614
  // `edit-source` command can't call the main-origin-only `/_api/edit-*`
@@ -7068,6 +7646,10 @@ function App() {
7068
7646
  })
7069
7647
  .then((r) => r.json().catch(() => ({})))
7070
7648
  .then((j) => {
7649
+ // Undo/redo lane (Cmd+Z/Cmd+Shift+Z re-application). The primary
7650
+ // edit-text lane above owns the user-facing revert + toast; a
7651
+ // refusal here is logged. (DDR-150 P1 kept this a warn after the
7652
+ // setStatus-scope fix — setStatus is not in App's scope.)
7071
7653
  if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
7072
7654
  })
7073
7655
  .catch(() => {})
@@ -7221,6 +7803,49 @@ function App() {
7221
7803
  // meta.json-derived seed when the canvas knows better. Clamp.
7222
7804
  const n = Math.round(m.count);
7223
7805
  if (Number.isFinite(n) && n >= 0 && n <= 999) setActiveArtboards(n);
7806
+ } else if (m.dgn === 'active-artboard') {
7807
+ // DDR-148 — canvas-lib reports the viewport-active artboard on pan. Gate
7808
+ // to the ACTIVE canvas window (a background canvas must not hijack the
7809
+ // Timeline's target). The Timeline re-parses to this artboard's comp.
7810
+ const activeWin =
7811
+ activePath && activePath !== SYSTEM_TAB
7812
+ ? iframesRef.current.get(activePath)?.contentWindow
7813
+ : null;
7814
+ if (e.source !== activeWin) return;
7815
+ // Cap the id like the sibling timeline-comps handler (defensive; inert).
7816
+ setCanvasActiveArtboard(typeof m.id === 'string' ? m.id.slice(0, 120) : null);
7817
+ } else if (m.dgn === 'timeline-comps' && Array.isArray(m.comps)) {
7818
+ // DDR-148 — a video-comp announces its comp meta (from video-comp.tsx).
7819
+ // Gate to the ACTIVE canvas window (phase-28 F-2 pattern): a background
7820
+ // canvas must not plant comps into the Timeline panel, which shows the
7821
+ // active canvas's comps. Inert display, but keep the seam closed.
7822
+ const activeWin =
7823
+ activePath && activePath !== SYSTEM_TAB
7824
+ ? iframesRef.current.get(activePath)?.contentWindow
7825
+ : null;
7826
+ if (e.source !== activeWin) return;
7827
+ const safe = m.comps
7828
+ .filter((c) => c && typeof c.id === 'string')
7829
+ .slice(0, 32)
7830
+ .map((c) => ({
7831
+ id: String(c.id).slice(0, 120),
7832
+ fps: Math.max(1, Math.min(120, Math.round(Number(c.fps) || 30))),
7833
+ durationInFrames: Math.max(1, Math.min(1_000_000, Math.round(Number(c.durationInFrames) || 1))),
7834
+ width: Math.max(1, Math.round(Number(c.width) || 0)),
7835
+ height: Math.max(1, Math.round(Number(c.height) || 0)),
7836
+ }));
7837
+ setActiveComps(safe);
7838
+ } else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
7839
+ // Live playhead mirror from the Player (preview scrub/playback).
7840
+ const activeWin =
7841
+ activePath && activePath !== SYSTEM_TAB
7842
+ ? iframesRef.current.get(activePath)?.contentWindow
7843
+ : null;
7844
+ if (e.source !== activeWin) return;
7845
+ // Only the Timeline's target comp drives the playhead — a sibling
7846
+ // artboard's Player must not fight it for the readout (multi-comp canvas).
7847
+ if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
7848
+ if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
7224
7849
  } else if (m.dgn === 'toggle-palette') {
7225
7850
  // ⌘K pressed while focus was inside the canvas iframe — the injected
7226
7851
  // inspector forwards the chord here since the iframe's keydown never
@@ -7357,6 +7982,15 @@ function App() {
7357
7982
  const fn = /filename="([^"]+)"/.exec(disp);
7358
7983
  const filename = (fn && fn[1]) || 'export';
7359
7984
  const blob = await r.blob();
7985
+ if (isNativeApp()) {
7986
+ // Native: route the bridged in-canvas export through the OS save dialog
7987
+ // too (WKWebView swallows the `<a download>` blob). Mirror the shell
7988
+ // ExportDialog's doExport native branch.
7989
+ const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
7990
+ const savedPath = await saveExport(filename, bytes);
7991
+ reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
7992
+ return;
7993
+ }
7360
7994
  const url = URL.createObjectURL(blob);
7361
7995
  const a = document.createElement('a');
7362
7996
  a.href = url;
@@ -7625,6 +8259,18 @@ function App() {
7625
8259
  // makes inspector CSS / inline text / attr edits undoable from anywhere.
7626
8260
  if (meta && !e.altKey && (e.key === 'z' || e.key === 'Z' || e.key === 'y' || e.key === 'Y')) {
7627
8261
  if (!inEditable && !inCanvasIframe && activePath && activePath !== SYSTEM_TAB) {
8262
+ // DDR-150 dogfood #1 (team finding) — SINGLE OWNER per keypress. With
8263
+ // the Timeline open on a video-comp canvas, the timeline undo stack
8264
+ // owns Cmd+Z/Shift+Cmd+Z (the timeline keydown effect performs it).
8265
+ // Without this skip, BOTH handlers fired on one keypress — undoing a
8266
+ // clip op AND popping the canvas's annotation undo simultaneously.
8267
+ if (
8268
+ (e.key === 'z' || e.key === 'Z') &&
8269
+ tlKeyRef.current.open &&
8270
+ tlKeyRef.current.comps?.length
8271
+ ) {
8272
+ return; // the timeline shortcuts effect claims it
8273
+ }
7628
8274
  e.preventDefault();
7629
8275
  const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
7630
8276
  postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
@@ -8007,6 +8653,9 @@ function App() {
8007
8653
  inspectorOpen={inspectorOpen}
8008
8654
  inspectorTab={inspectorTab}
8009
8655
  onToggleInspector={() => toggleRightPanel('inspector')}
8656
+ timelineOpen={timelineOpen}
8657
+ onToggleTimeline={toggleTimeline}
8658
+ hasComps={activeComps.length > 0}
8010
8659
  assistantOpen={assistantOpen}
8011
8660
  onToggleAssistant={() => toggleRightPanel('assistant')}
8012
8661
  assistantBusy={assistantBusy}
@@ -8028,6 +8677,7 @@ function App() {
8028
8677
  60
8029
8678
  );
8030
8679
  }}
8680
+ onAssembleVideo={assembleVideo}
8031
8681
  onOpenExport={(mode) => setExportDialog({ mode })}
8032
8682
  onReload={reloadActive}
8033
8683
  onCloseCanvas={() => activePath && closeTab(activePath)}
@@ -8219,6 +8869,347 @@ function App() {
8219
8869
  />
8220
8870
  )}
8221
8871
  </div>
8872
+ {/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
8873
+ above the status bar) — video timelines are horizontal. */}
8874
+ {timelineOpen && (
8875
+ <TimelinePanel
8876
+ comps={activeComps}
8877
+ sequences={timelineSequences}
8878
+ audio={timelineAudio}
8879
+ total={timelineTotal}
8880
+ frame={timelineFrame}
8881
+ playing={timelinePlaying}
8882
+ loop={timelineLoop}
8883
+ onSeek={(f) => {
8884
+ setTimelineFrame(f);
8885
+ setTimelinePlaying(false);
8886
+ postToActiveCanvas({ dgn: 'timeline-seek', frame: f, id: timelineCompId });
8887
+ }}
8888
+ onPlay={() => {
8889
+ setTimelinePlaying(true);
8890
+ // Sync mute + loop to the Player, then play (the artboard has no
8891
+ // chrome — the Timeline owns transport/sound/loop now).
8892
+ postToActiveCanvas({ dgn: 'timeline-mute', muted: timelineMuted, id: timelineCompId });
8893
+ postToActiveCanvas({ dgn: 'timeline-loop', loop: timelineLoop, id: timelineCompId });
8894
+ postToActiveCanvas({ dgn: 'timeline-play', id: timelineCompId });
8895
+ }}
8896
+ onPause={() => {
8897
+ setTimelinePlaying(false);
8898
+ postToActiveCanvas({ dgn: 'timeline-pause', id: timelineCompId });
8899
+ }}
8900
+ onToggleLoop={() =>
8901
+ setTimelineLoop((v) => {
8902
+ const next = !v;
8903
+ postToActiveCanvas({ dgn: 'timeline-loop', loop: next, id: timelineCompId });
8904
+ return next;
8905
+ })
8906
+ }
8907
+ muted={timelineMuted}
8908
+ onToggleMute={() => {
8909
+ setTimelineMuted((v) => {
8910
+ const next = !v;
8911
+ postToActiveCanvas({ dgn: 'timeline-mute', muted: next, id: timelineCompId });
8912
+ return next;
8913
+ });
8914
+ }}
8915
+ volume={timelineVolume}
8916
+ onVolume={(v) => {
8917
+ setTimelineVolume(v);
8918
+ // Dragging volume implies "I want to hear it" — unmute.
8919
+ if (v > 0 && timelineMuted) {
8920
+ setTimelineMuted(false);
8921
+ postToActiveCanvas({ dgn: 'timeline-mute', muted: false, id: timelineCompId });
8922
+ }
8923
+ postToActiveCanvas({ dgn: 'timeline-volume', volume: v, id: timelineCompId });
8924
+ }}
8925
+ onRetime={(index, patch) => {
8926
+ if (!activePath || activePath === SYSTEM_TAB) return;
8927
+ // DDR-150 P2 — address the clip by its comp-scoped stableId (from
8928
+ // /_api/comp-clips), NOT the raw row index, so a retime on a
8929
+ // multi-comp canvas can never mis-hit a clip in another comp. The
8930
+ // row index counts sequence rows only, so map it to the index-th
8931
+ // sequence clip (transitions filtered). Fall back to the legacy
8932
+ // index if the enumerator is unavailable.
8933
+ const artboardId = timelineArtboardId || undefined;
8934
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
8935
+ fetch(ccUrl)
8936
+ .then((r) => r.json().catch(() => ({})))
8937
+ .then((cc) => {
8938
+ const seqs =
8939
+ cc?.ok && Array.isArray(cc.clips)
8940
+ ? cc.clips.filter((c) => c.kind === 'sequence')
8941
+ : [];
8942
+ const clip = seqs[index] || null;
8943
+ const body = clip?.stableId
8944
+ ? {
8945
+ canvas: activePath,
8946
+ artboardId,
8947
+ stableId: clip.stableId,
8948
+ contentHash: clip.contentHash,
8949
+ ...patch,
8950
+ }
8951
+ : { canvas: activePath, index, ...patch };
8952
+ return fetch('/_api/retime-sequence', {
8953
+ method: 'POST',
8954
+ headers: { 'content-type': 'application/json' },
8955
+ body: JSON.stringify(body),
8956
+ });
8957
+ })
8958
+ .then((r) => r.json())
8959
+ .then((j) => {
8960
+ if (!j?.ok) {
8961
+ console.warn('[retime]', j?.error || 'failed');
8962
+ shellToast(`Retime refused: ${j?.error || 'failed'}`);
8963
+ } else if (j?.seq != null) {
8964
+ pushTlUndo(activePath, j.seq, patch.from != null ? 'move clip' : 'trim clip');
8965
+ }
8966
+ // The file watcher reloads the canvas → re-announce → the
8967
+ // source-fetch effect re-parses the new timing.
8968
+ })
8969
+ .catch(() => {});
8970
+ }}
8971
+ onRemove={(index) => {
8972
+ if (!activePath || activePath === SYSTEM_TAB) return;
8973
+ // DDR-150 P3 — remove the clip addressed by its comp-scoped stableId
8974
+ // (the sequence-row index → the index-th sequence clip). The engine
8975
+ // fingerprint + semantic gate refuse a stale/raced or series-breaking
8976
+ // removal; the file watcher reloads the canvas after.
8977
+ const artboardId = timelineArtboardId || undefined;
8978
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
8979
+ fetch(ccUrl)
8980
+ .then((r) => r.json().catch(() => ({})))
8981
+ .then((cc) => {
8982
+ const seqs =
8983
+ cc?.ok && Array.isArray(cc.clips)
8984
+ ? cc.clips.filter((c) => c.kind === 'sequence')
8985
+ : [];
8986
+ const clip = seqs[index] || null;
8987
+ if (!clip?.stableId) return null;
8988
+ return fetch('/_api/remove-sequence', {
8989
+ method: 'POST',
8990
+ headers: { 'content-type': 'application/json' },
8991
+ body: JSON.stringify({
8992
+ canvas: activePath,
8993
+ artboardId,
8994
+ stableId: clip.stableId,
8995
+ contentHash: clip.contentHash,
8996
+ }),
8997
+ });
8998
+ })
8999
+ .then((r) => (r ? r.json() : null))
9000
+ .then((j) => {
9001
+ if (j && !j.ok) {
9002
+ console.warn('[remove-clip]', j.error || 'failed');
9003
+ shellToast(`Remove refused: ${j.error || 'failed'}`);
9004
+ } else if (j?.seq != null) {
9005
+ pushTlUndo(activePath, j.seq, 'remove clip');
9006
+ }
9007
+ })
9008
+ .catch(() => {});
9009
+ }}
9010
+ onReplace={(index) => {
9011
+ if (!activePath || activePath === SYSTEM_TAB) return;
9012
+ // DDR-150 P3 + dogfood #5 — replace a clip's media. The file picker
9013
+ // MUST open synchronously inside the click gesture: browsers revoke
9014
+ // the transient user-activation after an await/fetch round-trip, so
9015
+ // the old fetch-then-click() silently no-oped ("replace neotevře
9016
+ // žádné okno"). Picker first; resolve the target + upload in the
9017
+ // change handler.
9018
+ replaceMediaViaPicker({
9019
+ accept: 'video/*,image/*',
9020
+ resolveTarget: (cc) => {
9021
+ const seqs =
9022
+ cc?.ok && Array.isArray(cc.clips)
9023
+ ? cc.clips.filter((c) => c.kind === 'sequence')
9024
+ : [];
9025
+ const clip = seqs[index];
9026
+ if (clip?.mediaArrayRef) return { arrayRef: clip.mediaArrayRef };
9027
+ if (clip?.mediaCdId) return { cdId: clip.mediaCdId };
9028
+ return null;
9029
+ },
9030
+ });
9031
+ }}
9032
+ onReplaceAudio={(index) => {
9033
+ if (!activePath || activePath === SYSTEM_TAB) return;
9034
+ // DDR-150 dogfood #5 — audio beds are addressable too: the
9035
+ // enumerator lists loose media (an <Audio> under the reel) with a
9036
+ // cd-id; ⇄ on the audio row swaps its src.
9037
+ replaceMediaViaPicker({
9038
+ accept: 'audio/*',
9039
+ resolveTarget: (cc) => {
9040
+ const beds =
9041
+ cc?.ok && Array.isArray(cc.media)
9042
+ ? cc.media.filter((m) => m.tag === 'Audio')
9043
+ : [];
9044
+ return beds[index]?.cdId ? { cdId: beds[index].cdId } : null;
9045
+ },
9046
+ });
9047
+ }}
9048
+ onReplaceLayer={(clipIndex, layerIndex) => {
9049
+ if (!activePath || activePath === SYSTEM_TAB) return;
9050
+ // DDR-150 dogfood — replace a SPECIFIC layer inside an expanded clip
9051
+ // (the mp4 background separately from the title layer). Targets the
9052
+ // layer's own media (array-fed or literal-src) from the enumerator.
9053
+ const kind =
9054
+ timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'audio'
9055
+ ? 'audio/*'
9056
+ : timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'image'
9057
+ ? 'image/*'
9058
+ : 'video/*';
9059
+ replaceMediaViaPicker({
9060
+ accept: kind,
9061
+ resolveTarget: (cc) => {
9062
+ const seqs =
9063
+ cc?.ok && Array.isArray(cc.clips)
9064
+ ? cc.clips.filter((c) => c.kind === 'sequence')
9065
+ : [];
9066
+ const ly = seqs[clipIndex]?.layers?.[layerIndex];
9067
+ if (ly?.mediaArrayRef) return { arrayRef: ly.mediaArrayRef };
9068
+ if (ly?.mediaCdId) return { cdId: ly.mediaCdId };
9069
+ return null;
9070
+ },
9071
+ });
9072
+ }}
9073
+ onReorder={(index, direction) => {
9074
+ if (!activePath || activePath === SYSTEM_TAB) return;
9075
+ // DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/
9076
+ // after a sibling (render stacking; later sibling paints on top).
9077
+ // ▲ forward = move AFTER the next sibling; ▼ backward = move BEFORE
9078
+ // the previous sibling. Both clips addressed by comp-scoped stableId +
9079
+ // fingerprint (via /_api/comp-clips); the engine refuses a TransitionSeries
9080
+ // clip + a stale/raced target, then reloads via the file watcher.
9081
+ const artboardId = timelineArtboardId || undefined;
9082
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
9083
+ fetch(ccUrl)
9084
+ .then((r) => r.json().catch(() => ({})))
9085
+ .then((cc) => {
9086
+ const seqs =
9087
+ cc?.ok && Array.isArray(cc.clips)
9088
+ ? cc.clips.filter((c) => c.kind === 'sequence')
9089
+ : [];
9090
+ const moved = seqs[index] || null;
9091
+ const refIdx = direction === 'forward' ? index + 1 : index - 1;
9092
+ const ref = seqs[refIdx] || null;
9093
+ const position = direction === 'forward' ? 'after' : 'before';
9094
+ if (!moved?.stableId || !ref?.stableId) return null;
9095
+ return fetch('/_api/reorder-sequence', {
9096
+ method: 'POST',
9097
+ headers: { 'content-type': 'application/json' },
9098
+ body: JSON.stringify({
9099
+ canvas: activePath,
9100
+ artboardId,
9101
+ stableId: moved.stableId,
9102
+ contentHash: moved.contentHash,
9103
+ refStableId: ref.stableId,
9104
+ refContentHash: ref.contentHash,
9105
+ position,
9106
+ }),
9107
+ });
9108
+ })
9109
+ .then((r) => (r ? r.json() : null))
9110
+ .then((j) => {
9111
+ if (j && !j.ok) {
9112
+ console.warn('[reorder-clip]', j.error || 'failed');
9113
+ shellToast(`Reorder refused: ${j.error || 'failed'}`);
9114
+ } else if (j?.seq != null) {
9115
+ pushTlUndo(activePath, j.seq, 'reorder clip');
9116
+ }
9117
+ })
9118
+ .catch(() => {});
9119
+ }}
9120
+ onToggleHide={(index) => {
9121
+ if (!activePath || activePath === SYSTEM_TAB) return;
9122
+ // DDR-150 dogfood — hide/show a clip (gates its body behind
9123
+ // {false && …}; the tag + time slot stay). Addressed by comp-scoped
9124
+ // stableId + fingerprint.
9125
+ const artboardId = timelineArtboardId || undefined;
9126
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
9127
+ fetch(ccUrl)
9128
+ .then((r) => r.json().catch(() => ({})))
9129
+ .then((cc) => {
9130
+ const seqs =
9131
+ cc?.ok && Array.isArray(cc.clips)
9132
+ ? cc.clips.filter((c) => c.kind === 'sequence')
9133
+ : [];
9134
+ const clip = seqs[index] || null;
9135
+ if (!clip?.stableId) return null;
9136
+ return fetch('/_api/toggle-hide', {
9137
+ method: 'POST',
9138
+ headers: { 'content-type': 'application/json' },
9139
+ body: JSON.stringify({
9140
+ canvas: activePath,
9141
+ artboardId,
9142
+ stableId: clip.stableId,
9143
+ contentHash: clip.contentHash,
9144
+ }),
9145
+ });
9146
+ })
9147
+ .then((r) => (r ? r.json() : null))
9148
+ .then((j) => {
9149
+ if (j && !j.ok) shellToast(`Hide refused: ${j.error || 'failed'}`);
9150
+ else if (j && j.ok) {
9151
+ shellToast(j.hidden ? 'Clip hidden.' : 'Clip shown.', true);
9152
+ if (j.seq != null) pushTlUndo(activePath, j.seq, j.hidden ? 'hide clip' : 'show clip');
9153
+ }
9154
+ })
9155
+ .catch(() => shellToast('Hide failed: network error'));
9156
+ }}
9157
+ onDropMedia={(file) => {
9158
+ if (!activePath || activePath === SYSTEM_TAB) return;
9159
+ // DDR-150 P4 Task 11 — drop a media file onto the timeline to
9160
+ // insert a clip: upload to assets/, then append a <Sequence> with
9161
+ // the media (playing after existing content). fps*3 default length.
9162
+ const artboardId = timelineArtboardId || undefined;
9163
+ const mediaTag = file.type.startsWith('video/')
9164
+ ? 'Video'
9165
+ : file.type.startsWith('audio/')
9166
+ ? 'Audio'
9167
+ : file.type.startsWith('image/')
9168
+ ? 'Img'
9169
+ : null;
9170
+ const fps = activeComps[0]?.fps || 30;
9171
+ fetch('/_api/asset', {
9172
+ method: 'POST',
9173
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
9174
+ body: file,
9175
+ })
9176
+ .then((r) => r.json().catch(() => ({})))
9177
+ .then((up) => {
9178
+ if (!up?.path) {
9179
+ console.warn('[insert] upload failed', up?.error);
9180
+ shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
9181
+ return null;
9182
+ }
9183
+ return fetch('/_api/insert-sequence', {
9184
+ method: 'POST',
9185
+ headers: { 'content-type': 'application/json' },
9186
+ body: JSON.stringify({
9187
+ canvas: activePath,
9188
+ artboardId,
9189
+ from: Math.max(0, timelineTotal),
9190
+ durationInFrames: Math.round(fps * 3),
9191
+ mediaTag,
9192
+ src: up.path,
9193
+ }),
9194
+ });
9195
+ })
9196
+ .then((r) => (r ? r.json() : null))
9197
+ .then((j) => {
9198
+ if (j && !j.ok) {
9199
+ console.warn('[insert-clip]', j.error || 'failed');
9200
+ shellToast(`Insert refused: ${j.error || 'failed'}`);
9201
+ } else if (j && j.ok) {
9202
+ shellToast('Clip added to the timeline.', true);
9203
+ if (j.seq != null) pushTlUndo(activePath, j.seq, 'add clip');
9204
+ }
9205
+ })
9206
+ .catch(() => {});
9207
+ }}
9208
+ height={timelineHeight}
9209
+ onResize={setTimelineHeight}
9210
+ onClose={() => setTimelineOpen(false)}
9211
+ />
9212
+ )}
8222
9213
  <StatusBar
8223
9214
  activePath={activePath}
8224
9215
  selected={selected}
@@ -8264,6 +9255,14 @@ function App() {
8264
9255
  mode={exportDialog.mode}
8265
9256
  initialScope={exportDialog.scope}
8266
9257
  activePath={activePath}
9258
+ hasComps={activeComps.length > 0}
9259
+ // Which artboard "Active artboard" scope targets. The shell can't query
9260
+ // the cross-origin canvas DOM (the in-canvas dialog's activeArtboardId),
9261
+ // so use the tracked signals: an explicit selection wins, else the
9262
+ // viewport-active artboard canvas-lib reports on pan. Without this,
9263
+ // scope=artboard fell back to `:first-of-type` (always the first).
9264
+ activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
9265
+ selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
8267
9266
  onClose={() => setExportDialog(null)}
8268
9267
  />
8269
9268
  )}