@hyperframes/studio 0.7.36 → 0.7.38

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 (75) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-BLRTwY5l.js +396 -0
  3. package/dist/assets/{index-BltxqwG6.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-_bjyggFK.js → index-Ykq7ihge.js} +1 -1
  6. package/dist/index.d.ts +35 -10
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5147 -3322
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +19 -18
  12. package/src/components/AskAgentModal.tsx +23 -6
  13. package/src/components/LintModal.tsx +40 -10
  14. package/src/components/MediaPreview.tsx +36 -1
  15. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  16. package/src/components/StudioErrorBoundary.tsx +19 -31
  17. package/src/components/StudioFeedbackBar.tsx +11 -2
  18. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  19. package/src/components/StudioHeader.tsx +184 -106
  20. package/src/components/StudioLeftSidebar.tsx +24 -3
  21. package/src/components/StudioOverlays.tsx +34 -8
  22. package/src/components/StudioRightPanel.tsx +154 -9
  23. package/src/components/StudioSplash.tsx +6 -3
  24. package/src/components/StudioToast.tsx +12 -11
  25. package/src/components/TimelineToolbar.test.tsx +9 -3
  26. package/src/components/TimelineToolbar.tsx +63 -29
  27. package/src/components/editor/PropertyPanel.tsx +18 -17
  28. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  29. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  30. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  31. package/src/components/editor/manualEditingAvailability.ts +0 -6
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  33. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  34. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  35. package/src/components/editor/propertyPanelHelpers.ts +22 -91
  36. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  37. package/src/components/editor/propertyPanelTypes.ts +111 -0
  38. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  39. package/src/components/nle/NLELayout.tsx +54 -51
  40. package/src/components/nle/NLEPreview.tsx +41 -5
  41. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  42. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  43. package/src/components/renders/RenderQueue.tsx +125 -24
  44. package/src/components/renders/RenderQueueItem.tsx +132 -64
  45. package/src/components/renders/useRenderQueue.ts +136 -24
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  47. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  48. package/src/components/storyboard/StoryboardView.tsx +6 -0
  49. package/src/components/studioColorGradingScope.ts +124 -0
  50. package/src/components/studioMediaJobs.ts +123 -0
  51. package/src/components/ui/Button.tsx +20 -11
  52. package/src/components/ui/HyperframesLoader.tsx +9 -2
  53. package/src/components/ui/SearchInput.tsx +53 -0
  54. package/src/components/ui/Tooltip.tsx +52 -6
  55. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  56. package/src/components/ui/useDialogBehavior.ts +83 -0
  57. package/src/contexts/PanelLayoutContext.tsx +3 -0
  58. package/src/contexts/StudioContext.tsx +7 -0
  59. package/src/hooks/useCompositionContentLoader.ts +40 -0
  60. package/src/hooks/useEditorSave.ts +14 -0
  61. package/src/hooks/useFileManager.ts +39 -25
  62. package/src/hooks/useFrameCapture.ts +12 -1
  63. package/src/hooks/usePanelLayout.ts +1 -0
  64. package/src/hooks/usePreviewPersistence.ts +24 -15
  65. package/src/hooks/useStudioContextValue.ts +5 -0
  66. package/src/hooks/useToast.ts +66 -13
  67. package/src/styles/studio.css +61 -0
  68. package/src/utils/sdkResolverShadow.test.ts +70 -3
  69. package/src/utils/sdkResolverShadow.ts +47 -4
  70. package/src/utils/studioPendingEdits.test.ts +43 -0
  71. package/src/utils/studioPendingEdits.ts +45 -0
  72. package/src/utils/studioUiPreferences.ts +4 -0
  73. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  74. package/dist/assets/index-DfeE1_Rl.js +0 -396
  75. package/dist/assets/index-DmkOvZns.css +0 -1
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
30
30
  primary: [
31
31
  "bg-white text-neutral-950 font-medium",
32
32
  "shadow-btn-primary",
33
- "hover:bg-neutral-200",
34
- "active:scale-[0.97]",
33
+ "enabled:hover:bg-neutral-200",
34
+ "enabled:active:scale-[0.97]",
35
35
  ].join(" "),
36
36
  secondary: [
37
37
  "bg-transparent text-neutral-300 font-medium",
38
38
  "border border-border",
39
- "hover:bg-surface-hover hover:text-white hover:border-border-strong",
40
- "active:scale-[0.98]",
39
+ "enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
40
+ "enabled:active:scale-[0.98]",
41
+ ].join(" "),
42
+ danger: [
43
+ "bg-accent-red text-white font-medium",
44
+ "enabled:hover:bg-red-600",
45
+ "enabled:active:scale-[0.97]",
41
46
  ].join(" "),
42
- danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
43
- " ",
44
- ),
45
47
  ghost: [
46
48
  "bg-transparent text-neutral-400",
47
- "hover:bg-surface-hover hover:text-white",
48
- "active:scale-[0.98]",
49
+ "enabled:hover:bg-surface-hover enabled:hover:text-white",
50
+ "enabled:active:scale-[0.98]",
49
51
  ].join(" "),
50
52
  };
51
53
 
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
55
57
  lg: "h-9 px-4 text-base gap-2 rounded-button",
56
58
  };
57
59
 
60
+ // Imported by the shell/renders PRs later in this stack.
61
+ // fallow-ignore-next-line unused-export
58
62
  export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
59
63
  (
60
64
  {
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
76
80
  className={[
77
81
  "inline-flex items-center justify-center",
78
82
  "transition-all duration-press ease-standard",
79
- "disabled:opacity-40 disabled:pointer-events-none",
83
+ // No pointer-events-none: disabled buttons must still receive hover
84
+ // so a wrapping Tooltip can explain WHY they're disabled (A5).
85
+ "disabled:opacity-40 disabled:cursor-not-allowed",
80
86
  "select-none cursor-pointer",
87
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
81
88
  variantStyles[variant],
82
89
  sizeStyles[size],
83
90
  className,
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
126
133
  lg: "min-w-9 min-h-9 rounded-button", // 36px
127
134
  };
128
135
 
136
+ // fallow-ignore-next-line unused-export
129
137
  export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
130
138
  ({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
131
139
  return (
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
134
142
  className={[
135
143
  "inline-flex items-center justify-center",
136
144
  "transition-all duration-press ease-standard",
137
- "disabled:opacity-40 disabled:pointer-events-none",
145
+ "disabled:opacity-40 disabled:cursor-not-allowed",
138
146
  "select-none cursor-pointer",
147
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
139
148
  variantStyles[variant],
140
149
  iconSizeStyles[size],
141
150
  className,
@@ -25,7 +25,7 @@ export function HyperframesLoader({
25
25
  const markFrameSize = Math.round(size * 1.16);
26
26
 
27
27
  return (
28
- <div className="hf-loader" draggable={false}>
28
+ <div className="hf-loader" role="status" draggable={false}>
29
29
  <div
30
30
  className="hf-loader-mark-frame"
31
31
  style={{ width: markFrameSize, height: markFrameSize }}
@@ -83,7 +83,13 @@ export function HyperframesLoader({
83
83
  <div className="hf-loader-title">{title}</div>
84
84
  {detail && <div className="hf-loader-detail">{detail}</div>}
85
85
  {boundedProgress !== undefined && (
86
- <div className="hf-loader-progress" aria-hidden="true">
86
+ <div
87
+ className="hf-loader-progress"
88
+ role="progressbar"
89
+ aria-valuemin={0}
90
+ aria-valuemax={100}
91
+ aria-valuenow={Math.round(boundedProgress * 100)}
92
+ >
87
93
  <div
88
94
  className="hf-loader-progress__fill"
89
95
  style={{ transform: `scaleX(${boundedProgress})` }}
@@ -95,6 +101,7 @@ export function HyperframesLoader({
95
101
  );
96
102
  }
97
103
 
104
+ // fallow-ignore-next-line unused-export
98
105
  export function StatusFrame(props: HyperframesLoaderProps) {
99
106
  return (
100
107
  <div className="hf-frame">
@@ -0,0 +1,53 @@
1
+ // fallow-ignore-file unused-file
2
+ // (consumers land in the sidebar/panels PR later in this stack)
3
+ import { type InputHTMLAttributes } from "react";
4
+
5
+ interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
6
+ /** Accessible name — placeholder alone is not one. */
7
+ "aria-label": string;
8
+ }
9
+
10
+ /**
11
+ * Shared search input — one visual system (panel-input tokens) for every
12
+ * panel search box, with a required accessible name.
13
+ */
14
+ export function SearchInput({ className = "", ...props }: SearchInputProps) {
15
+ return (
16
+ <div
17
+ className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
18
+ >
19
+ <svg
20
+ width="12"
21
+ height="12"
22
+ viewBox="0 0 256 256"
23
+ fill="none"
24
+ className="flex-shrink-0"
25
+ aria-hidden="true"
26
+ >
27
+ <circle
28
+ cx="116"
29
+ cy="116"
30
+ r="76"
31
+ stroke="currentColor"
32
+ strokeWidth="22"
33
+ className="text-panel-text-5"
34
+ />
35
+ <line
36
+ x1="170"
37
+ y1="170"
38
+ x2="232"
39
+ y2="232"
40
+ stroke="currentColor"
41
+ strokeWidth="22"
42
+ strokeLinecap="round"
43
+ className="text-panel-text-5"
44
+ />
45
+ </svg>
46
+ <input
47
+ type="text"
48
+ className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
49
+ {...props}
50
+ />
51
+ </div>
52
+ );
53
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useRef, useCallback, type ReactNode } from "react";
1
+ import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
 
4
4
  interface TooltipProps {
@@ -8,22 +8,46 @@ interface TooltipProps {
8
8
  side?: "top" | "bottom";
9
9
  }
10
10
 
11
+ // Rough bubble height (padding + one text line) used to decide flipping
12
+ // before the bubble has rendered; exact height isn't needed for the guard.
13
+ const APPROX_BUBBLE_H = 28;
14
+ const VIEWPORT_MARGIN = 8;
15
+
11
16
  export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
12
17
  const [visible, setVisible] = useState(false);
13
18
  const [pos, setPos] = useState({ x: 0, y: 0 });
19
+ const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
14
20
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
21
  const triggerRef = useRef<HTMLSpanElement>(null);
22
+ // WCAG 4.1.2: programmatically associate the bubble with its trigger.
23
+ const tooltipId = useId();
16
24
 
17
25
  const show = useCallback(() => {
26
+ if (timerRef.current) clearTimeout(timerRef.current);
18
27
  timerRef.current = setTimeout(() => {
19
28
  const el = triggerRef.current;
20
29
  if (!el) return;
21
30
  const child = el.firstElementChild as HTMLElement | null;
22
31
  const rect = (child ?? el).getBoundingClientRect();
23
32
  if (rect.width === 0 && rect.height === 0) return;
33
+ // Flip when the preferred side would clip the viewport edge.
34
+ let nextSide = side;
35
+ if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
36
+ nextSide = "bottom";
37
+ } else if (
38
+ side === "bottom" &&
39
+ rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
40
+ ) {
41
+ nextSide = "top";
42
+ }
43
+ const x = Math.min(
44
+ Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
45
+ window.innerWidth - VIEWPORT_MARGIN,
46
+ );
47
+ setResolvedSide(nextSide);
24
48
  setPos({
25
- x: rect.left + rect.width / 2,
26
- y: side === "top" ? rect.top - 6 : rect.bottom + 6,
49
+ x,
50
+ y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
27
51
  });
28
52
  setVisible(true);
29
53
  }, delay);
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
37
61
  setVisible(false);
38
62
  }, []);
39
63
 
64
+ // WCAG 1.4.13: tooltip content must be dismissible with Escape.
65
+ useEffect(() => {
66
+ if (!visible) return;
67
+ const onKeyDown = (e: KeyboardEvent) => {
68
+ if (e.key === "Escape") hide();
69
+ };
70
+ document.addEventListener("keydown", onKeyDown);
71
+ return () => document.removeEventListener("keydown", onKeyDown);
72
+ }, [visible, hide]);
73
+
40
74
  return (
41
75
  <>
42
- <span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
76
+ <span
77
+ ref={triggerRef}
78
+ onPointerEnter={show}
79
+ onPointerLeave={hide}
80
+ onFocus={show}
81
+ onBlur={hide}
82
+ aria-describedby={visible ? tooltipId : undefined}
83
+ className="contents"
84
+ >
43
85
  {children}
44
86
  </span>
45
87
  {visible &&
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
49
91
  style={{
50
92
  left: pos.x,
51
93
  top: pos.y,
52
- transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
94
+ transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
53
95
  }}
54
96
  >
55
- <div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
97
+ <div
98
+ role="tooltip"
99
+ id={tooltipId}
100
+ className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
101
+ >
56
102
  {label}
57
103
  </div>
58
104
  </div>,
@@ -5,10 +5,19 @@ import { useState, useEffect } from "react";
5
5
  * video + canvas. Seeks to ~10% of duration to avoid black opening frames.
6
6
  * Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
7
7
  */
8
- export function VideoFrameThumbnail({ src }: { src: string }) {
8
+ export function VideoFrameThumbnail({
9
+ src,
10
+ fallbackLabel,
11
+ }: {
12
+ src: string;
13
+ /** Shown instead of an endless shimmer when the video can't be decoded. */
14
+ fallbackLabel?: string;
15
+ }) {
9
16
  const [frame, setFrame] = useState<string | null>(null);
17
+ const [failed, setFailed] = useState(false);
10
18
 
11
19
  useEffect(() => {
20
+ setFailed(false);
12
21
  const video = document.createElement("video");
13
22
  video.crossOrigin = "anonymous";
14
23
  video.muted = true;
@@ -35,15 +44,29 @@ export function VideoFrameThumbnail({ src }: { src: string }) {
35
44
  cleanup();
36
45
  });
37
46
 
38
- video.addEventListener("error", cleanup);
47
+ video.addEventListener("error", () => {
48
+ // Resolve the loading state — a permanent shimmer reads as "still loading".
49
+ setFailed(true);
50
+ cleanup();
51
+ });
39
52
  video.src = src;
40
53
  video.load();
41
54
 
42
55
  return cleanup;
43
56
  }, [src]);
44
57
 
58
+ if (failed && !frame) {
59
+ return (
60
+ <div className="w-full h-full bg-neutral-800 flex items-center justify-center">
61
+ <span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
62
+ </div>
63
+ );
64
+ }
65
+
45
66
  if (!frame) {
46
- return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
67
+ return (
68
+ <div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
69
+ );
47
70
  }
48
71
 
49
72
  return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
@@ -0,0 +1,83 @@
1
+ // fallow-ignore-file unused-file
2
+ // (consumers land in the shell/sidebar PRs later in this stack)
3
+ import { useEffect, useCallback, useRef, type RefObject } from "react";
4
+
5
+ const FOCUSABLE =
6
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
7
+
8
+ interface DialogBehaviorOptions {
9
+ open: boolean;
10
+ onClose: () => void;
11
+ containerRef: RefObject<HTMLElement | null>;
12
+ /**
13
+ * Return false to veto a close triggered by Escape/backdrop (e.g. a dirty
14
+ * draft the user hasn't submitted). Direct onClose calls are not guarded.
15
+ */
16
+ canClose?: () => boolean;
17
+ }
18
+
19
+ /**
20
+ * Shared dialog contract for the studio's custom modals: document-level
21
+ * Escape, Tab focus trap, focus-first-control on open, focus restore on close.
22
+ * The consumer still renders its own markup and should set role="dialog" and
23
+ * aria-modal="true" on the container.
24
+ */
25
+ export function useDialogBehavior({
26
+ open,
27
+ onClose,
28
+ containerRef,
29
+ canClose,
30
+ }: DialogBehaviorOptions) {
31
+ const restoreRef = useRef<HTMLElement | null>(null);
32
+ const canCloseRef = useRef(canClose);
33
+ canCloseRef.current = canClose;
34
+ const onCloseRef = useRef(onClose);
35
+ onCloseRef.current = onClose;
36
+
37
+ const requestClose = useCallback(() => {
38
+ const guard = canCloseRef.current;
39
+ if (guard && !guard()) return;
40
+ onCloseRef.current();
41
+ }, []);
42
+
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const previouslyFocused = document.activeElement;
46
+ restoreRef.current = previouslyFocused instanceof HTMLElement ? previouslyFocused : null;
47
+
48
+ const container = containerRef.current;
49
+ const first = container?.querySelector<HTMLElement>(FOCUSABLE);
50
+ (first ?? container)?.focus();
51
+
52
+ const onKeyDown = (e: KeyboardEvent) => {
53
+ if (e.key === "Escape") {
54
+ e.stopPropagation();
55
+ requestClose();
56
+ return;
57
+ }
58
+ if (e.key !== "Tab") return;
59
+ const el = containerRef.current;
60
+ if (!el) return;
61
+ const focusables = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));
62
+ if (focusables.length === 0) return;
63
+ const firstEl = focusables[0];
64
+ const lastEl = focusables[focusables.length - 1];
65
+ const active = document.activeElement;
66
+ if (e.shiftKey && (active === firstEl || !el.contains(active))) {
67
+ e.preventDefault();
68
+ lastEl.focus();
69
+ } else if (!e.shiftKey && (active === lastEl || !el.contains(active))) {
70
+ e.preventDefault();
71
+ firstEl.focus();
72
+ }
73
+ };
74
+ document.addEventListener("keydown", onKeyDown, true);
75
+ return () => {
76
+ document.removeEventListener("keydown", onKeyDown, true);
77
+ restoreRef.current?.focus();
78
+ restoreRef.current = null;
79
+ };
80
+ }, [open, containerRef, requestClose]);
81
+
82
+ return { requestClose };
83
+ }
@@ -16,6 +16,7 @@ export function PanelLayoutProvider({
16
16
  leftWidth,
17
17
  setLeftWidth,
18
18
  rightWidth,
19
+ setRightWidth,
19
20
  leftCollapsed,
20
21
  setLeftCollapsed,
21
22
  rightCollapsed,
@@ -39,6 +40,7 @@ export function PanelLayoutProvider({
39
40
  leftWidth,
40
41
  setLeftWidth,
41
42
  rightWidth,
43
+ setRightWidth,
42
44
  leftCollapsed,
43
45
  setLeftCollapsed,
44
46
  rightCollapsed,
@@ -56,6 +58,7 @@ export function PanelLayoutProvider({
56
58
  leftWidth,
57
59
  setLeftWidth,
58
60
  rightWidth,
61
+ setRightWidth,
59
62
  leftCollapsed,
60
63
  setLeftCollapsed,
61
64
  rightCollapsed,
@@ -19,7 +19,12 @@ export interface StudioShellValue {
19
19
  renderQueue: {
20
20
  jobs: unknown[];
21
21
  isRendering: boolean;
22
+ loadError: string | null;
23
+ actionError: string | null;
24
+ dismissActionError: () => void;
25
+ reloadRenders: () => void;
22
26
  deleteRender: (jobId: string) => void;
27
+ cancelRender: (jobId: string) => void;
23
28
  clearCompleted: () => void;
24
29
  startRender: (options: unknown) => Promise<void>;
25
30
  };
@@ -58,6 +63,7 @@ export function useStudioPlaybackContext(): StudioPlaybackValue {
58
63
  }
59
64
 
60
65
  /** @deprecated Use useStudioShellContext and/or useStudioPlaybackContext instead. */
66
+ // fallow-ignore-next-line unused-export
61
67
  export function useStudioContext(): StudioContextValue {
62
68
  const shell = useStudioShellContext();
63
69
  const playback = useStudioPlaybackContext();
@@ -166,6 +172,7 @@ export function StudioPlaybackProvider({
166
172
  }
167
173
 
168
174
  /** @deprecated Use StudioShellProvider and StudioPlaybackProvider instead. */
175
+ // fallow-ignore-next-line unused-export
169
176
  export function StudioProvider({
170
177
  value,
171
178
  children,
@@ -0,0 +1,40 @@
1
+ import { useCallback } from "react";
2
+
3
+ /**
4
+ * Loads a composition file's content for the source editor when a composition
5
+ * is selected. Content stays null until the fetch resolves — the source editor
6
+ * must not mount on a null-content file, or its autosave would overwrite the
7
+ * real file with an empty document. Load failures surface as an error toast
8
+ * instead of silently rendering an empty (and autosave-armed) editor.
9
+ */
10
+ export function useCompositionContentLoader({
11
+ projectId,
12
+ setEditingFile,
13
+ setActiveCompPath,
14
+ showToast,
15
+ }: {
16
+ projectId: string | null;
17
+ setEditingFile: (file: { path: string; content: string | null }) => void;
18
+ setActiveCompPath: (path: string | null) => void;
19
+ showToast: (message: string, tone?: "error" | "info") => void;
20
+ }) {
21
+ return useCallback(
22
+ (comp: string) => {
23
+ setActiveCompPath(comp.endsWith(".html") ? comp : null);
24
+ setEditingFile({ path: comp, content: null });
25
+ fetch(`/api/projects/${projectId}/files/${comp}`)
26
+ .then(async (r) => {
27
+ if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
28
+ return r.json();
29
+ })
30
+ .then((data: { content?: string }) => {
31
+ if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
32
+ setEditingFile({ path: comp, content: data.content });
33
+ })
34
+ .catch((err) => {
35
+ showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
36
+ });
37
+ },
38
+ [projectId, setEditingFile, setActiveCompPath, showToast],
39
+ );
40
+ }
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
18
18
  recordEdit: (input: RecordEditInput) => Promise<void>;
19
19
  domEditSaveTimestampRef: React.MutableRefObject<number>;
20
20
  setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
21
+ showToast: (message: string, tone?: "error" | "info") => void;
21
22
  }
22
23
 
23
24
  export function useEditorSave({
@@ -28,9 +29,13 @@ export function useEditorSave({
28
29
  recordEdit,
29
30
  domEditSaveTimestampRef,
30
31
  setRefreshKey,
32
+ showToast,
31
33
  }: UseEditorSaveOptions) {
32
34
  const saveRafRef = useRef<number | null>(null);
33
35
  const refreshRafRef = useRef<number | null>(null);
36
+ // One error toast per burst of failures — every keystroke retries the save,
37
+ // and error toasts persist until dismissed, so don't stack duplicates.
38
+ const lastFailureToastAtRef = useRef(0);
34
39
 
35
40
  const handleContentChange = useCallback(
36
41
  (content: string) => {
@@ -61,6 +66,14 @@ export function useEditorSave({
61
66
  source: "code_editor",
62
67
  error_message: error instanceof Error ? error.message : "unknown",
63
68
  });
69
+ const now = Date.now();
70
+ if (now - lastFailureToastAtRef.current > 5000) {
71
+ lastFailureToastAtRef.current = now;
72
+ showToast(
73
+ `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
74
+ "error",
75
+ );
76
+ }
64
77
  });
65
78
  });
66
79
  },
@@ -71,6 +84,7 @@ export function useEditorSave({
71
84
  readProjectFile,
72
85
  recordEdit,
73
86
  setRefreshKey,
87
+ showToast,
74
88
  writeProjectFile,
75
89
  ],
76
90
  );
@@ -126,6 +126,7 @@ export function useFileManager({
126
126
  recordEdit,
127
127
  domEditSaveTimestampRef,
128
128
  setRefreshKey,
129
+ showToast,
129
130
  });
130
131
 
131
132
  // ── File select ──
@@ -133,26 +134,34 @@ export function useFileManager({
133
134
  const revealRequestIdRef = useRef(0);
134
135
  const revealAbortRef = useRef<AbortController | null>(null);
135
136
 
136
- const handleFileSelect = useCallback((path: string) => {
137
- const pid = projectIdRef.current;
138
- if (!pid) return;
139
- revealAbortRef.current?.abort();
140
- revealAbortRef.current = null;
141
- revealRequestIdRef.current++;
142
- // Skip fetching binary content for media files — just set the path for preview
143
- if (isMediaFile(path)) {
144
- setEditingFile({ path, content: null });
145
- return;
146
- }
147
- fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
148
- .then((r) => r.json())
149
- .then((data: { content?: string }) => {
150
- if (data.content != null) {
151
- setEditingFile({ path, content: data.content });
152
- }
153
- })
154
- .catch(() => {});
155
- }, []);
137
+ const handleFileSelect = useCallback(
138
+ (path: string) => {
139
+ const pid = projectIdRef.current;
140
+ if (!pid) return;
141
+ revealAbortRef.current?.abort();
142
+ revealAbortRef.current = null;
143
+ revealRequestIdRef.current++;
144
+ // Skip fetching binary content for media files — just set the path for preview
145
+ if (isMediaFile(path)) {
146
+ setEditingFile({ path, content: null });
147
+ return;
148
+ }
149
+ fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
150
+ .then((r) => {
151
+ if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
152
+ return r.json();
153
+ })
154
+ .then((data: { content?: string }) => {
155
+ if (data.content != null) {
156
+ setEditingFile({ path, content: data.content });
157
+ }
158
+ })
159
+ .catch((err: unknown) => {
160
+ showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
161
+ });
162
+ },
163
+ [showToast],
164
+ );
156
165
 
157
166
  // ── Click-to-source ──
158
167
 
@@ -253,9 +262,10 @@ export function useFileManager({
253
262
  } else {
254
263
  const err = await res.json().catch(() => ({ error: "unknown" }));
255
264
  console.error(`Create file failed: ${err.error}`);
265
+ showToast(`Couldn't create ${path}: ${err.error}`, "error");
256
266
  }
257
267
  },
258
- [refreshFileTree, handleFileSelect],
268
+ [refreshFileTree, handleFileSelect, showToast],
259
269
  );
260
270
 
261
271
  const handleCreateFolder = useCallback(
@@ -275,9 +285,10 @@ export function useFileManager({
275
285
  } else {
276
286
  const err = await res.json().catch(() => ({ error: "unknown" }));
277
287
  console.error(`Create folder failed: ${err.error}`);
288
+ showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
278
289
  }
279
290
  },
280
- [refreshFileTree],
291
+ [refreshFileTree, showToast],
281
292
  );
282
293
 
283
294
  const handleDeleteFile = useCallback(
@@ -293,9 +304,10 @@ export function useFileManager({
293
304
  } else {
294
305
  const err = await res.json().catch(() => ({ error: "unknown" }));
295
306
  console.error(`Delete failed: ${err.error}`);
307
+ showToast(`Couldn't delete ${path}: ${err.error}`, "error");
296
308
  }
297
309
  },
298
- [refreshFileTree],
310
+ [refreshFileTree, showToast],
299
311
  );
300
312
 
301
313
  const handleRenameFile = useCallback(
@@ -316,9 +328,10 @@ export function useFileManager({
316
328
  } else {
317
329
  const err = await res.json().catch(() => ({ error: "unknown" }));
318
330
  console.error(`Rename failed: ${err.error}`);
331
+ showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
319
332
  }
320
333
  },
321
- [refreshFileTree, handleFileSelect, setRefreshKey],
334
+ [refreshFileTree, handleFileSelect, setRefreshKey, showToast],
322
335
  );
323
336
 
324
337
  const handleDuplicateFile = useCallback(
@@ -337,9 +350,10 @@ export function useFileManager({
337
350
  } else {
338
351
  const err = await res.json().catch(() => ({ error: "unknown" }));
339
352
  console.error(`Duplicate failed: ${err.error}`);
353
+ showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
340
354
  }
341
355
  },
342
- [refreshFileTree, handleFileSelect],
356
+ [refreshFileTree, handleFileSelect, showToast],
343
357
  );
344
358
 
345
359
  const handleMoveFile = handleRenameFile;