@hyperframes/studio 0.7.37 → 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-BXYJbIax.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-Ct-X2wlf.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 +5146 -3321
  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 +53 -3
  69. package/src/utils/sdkResolverShadow.ts +41 -1
  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-DmkOvZns.css +0 -1
  75. package/dist/assets/index-SYSbQyK4.js +0 -396
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.37",
3
+ "version": "0.7.38",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.37",
50
- "@hyperframes/parsers": "0.7.37",
51
- "@hyperframes/player": "0.7.37",
52
- "@hyperframes/sdk": "0.7.37",
53
- "@hyperframes/studio-server": "0.7.37"
49
+ "@hyperframes/core": "0.7.38",
50
+ "@hyperframes/parsers": "0.7.38",
51
+ "@hyperframes/sdk": "0.7.38",
52
+ "@hyperframes/studio-server": "0.7.38",
53
+ "@hyperframes/player": "0.7.38"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.37"
68
+ "@hyperframes/producer": "0.7.38"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -27,6 +27,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
27
27
  import { useLintModal } from "./hooks/useLintModal";
28
28
  import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
29
29
  import { useToast } from "./hooks/useToast";
30
+ import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
30
31
  import { useStudioUrlState } from "./hooks/useStudioUrlState";
31
32
  import {
32
33
  buildStudioContextValue,
@@ -64,7 +65,6 @@ export function StudioApp() {
64
65
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
65
66
  const viewModeValue = useViewModeState();
66
67
 
67
- // sessionStorage-backed: fires once per tab, survives HMR remounts
68
68
  useEffect(() => {
69
69
  if (resolving || waitingForServer) return;
70
70
  if (hasFiredSessionStart()) return;
@@ -133,7 +133,7 @@ export function StudioApp() {
133
133
  return !v;
134
134
  });
135
135
  }, []);
136
- const { appToast, showToast, dismissToast } = useToast();
136
+ const { toasts, showToast, dismissToast } = useToast();
137
137
  const panelLayout = usePanelLayout({
138
138
  rightCollapsed: initialUrlStateRef.current.rightCollapsed,
139
139
  rightPanelTab: initialUrlStateRef.current.rightPanelTab,
@@ -390,17 +390,13 @@ export function StudioApp() {
390
390
  },
391
391
  [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
392
392
  );
393
- const handleSelectComposition = useCallback(
394
- (comp: string) => {
395
- setActiveCompPath(comp.endsWith(".html") ? comp : null);
396
- fileManager.setEditingFile({ path: comp, content: null });
397
- fetch(`/api/projects/${projectId}/files/${comp}`)
398
- .then((r) => r.json())
399
- .then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
400
- .catch(() => {});
401
- },
402
- [projectId, fileManager],
403
- );
393
+ const { setEditingFile } = fileManager;
394
+ const handleSelectComposition = useCompositionContentLoader({
395
+ projectId,
396
+ setEditingFile,
397
+ setActiveCompPath,
398
+ showToast,
399
+ });
404
400
  const {
405
401
  designPanelActive,
406
402
  inspectorPanelActive,
@@ -485,14 +481,20 @@ export function StudioApp() {
485
481
  captureFrameFilename={frameCapture.captureFrameFilename}
486
482
  handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
487
483
  refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
484
+ capturing={frameCapture.capturing}
488
485
  inspectorButtonActive={inspectorButtonActive}
489
486
  inspectorPanelActive={inspectorPanelActive}
490
- onExport={() => void renderQueue.startRender(undefined)}
487
+ onExport={() => {
488
+ void (async () => {
489
+ await previewPersistence.waitForPendingDomEditSaves();
490
+ await renderQueue.startRender(undefined);
491
+ })();
492
+ }}
491
493
  />
492
494
  {previewPersistence.domEditSaveQueuePaused && (
493
495
  <SaveQueuePausedBanner
494
496
  message={previewPersistence.domEditSaveQueuePaused}
495
- onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
497
+ onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
496
498
  />
497
499
  )}
498
500
  {viewModeValue.viewMode === "storyboard" && (
@@ -501,8 +503,6 @@ export function StudioApp() {
501
503
  onSelectComposition={handleSelectComposition}
502
504
  />
503
505
  )}
504
- {/* Timeline stage stays mounted (just hidden) in storyboard mode,
505
- so preview/player/gesture/render state survives the toggle. */}
506
506
  <div
507
507
  className={`flex flex-1 min-h-0${
508
508
  viewModeValue.viewMode === "storyboard" ? " hidden" : ""
@@ -576,6 +576,7 @@ export function StudioApp() {
576
576
  </div>
577
577
  <StudioOverlays
578
578
  projectId={projectId}
579
+ projectDir={fileManager.projectDir}
579
580
  lintModal={lintModal}
580
581
  closeLintModal={closeLintModal}
581
582
  consoleErrors={consoleErrors}
@@ -583,7 +584,7 @@ export function StudioApp() {
583
584
  domEditSession={domEditSession}
584
585
  activeCompPath={activeCompPath}
585
586
  dragOverlayActive={dragOverlay.active}
586
- appToast={appToast}
587
+ toasts={toasts}
587
588
  dismissToast={dismissToast}
588
589
  />
589
590
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useState, useRef, type CSSProperties } from "react";
2
2
  import { useMountEffect } from "../hooks/useMountEffect";
3
3
  import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers";
4
+ import { useDialogBehavior } from "./ui/useDialogBehavior";
4
5
 
5
6
  function getAgentModalPositionStyle(
6
7
  anchorPoint: AgentModalAnchorPoint | null,
@@ -39,7 +40,16 @@ export function AskAgentModal({
39
40
  }) {
40
41
  const [value, setValue] = useState("");
41
42
  const inputRef = useRef<HTMLTextAreaElement>(null);
43
+ const containerRef = useRef<HTMLDivElement>(null);
42
44
  const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
45
+ // A dirty draft vetoes Escape/backdrop closes — a stray click must not
46
+ // discard typed instructions. The X button and Copy still close directly.
47
+ const { requestClose } = useDialogBehavior({
48
+ open: true,
49
+ onClose,
50
+ containerRef,
51
+ canClose: () => !value.trim(),
52
+ });
43
53
 
44
54
  useMountEffect(() => {
45
55
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -54,13 +64,18 @@ export function AskAgentModal({
54
64
  <div
55
65
  className={
56
66
  anchorPoint
57
- ? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
58
- : "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
67
+ ? "hf-backdrop-in fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
68
+ : "hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
59
69
  }
60
- onClick={onClose}
70
+ onClick={requestClose}
61
71
  >
62
72
  <div
63
- className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
73
+ ref={containerRef}
74
+ role="dialog"
75
+ aria-modal="true"
76
+ aria-label="Copy prompt to AI agent"
77
+ tabIndex={-1}
78
+ className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl outline-none ${
64
79
  anchorPoint ? "fixed" : ""
65
80
  }`}
66
81
  style={modalPositionStyle}
@@ -74,8 +89,9 @@ export function AskAgentModal({
74
89
  </p>
75
90
  </div>
76
91
  <button
77
- className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
92
+ className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50 active:scale-[0.98]"
78
93
  onClick={onClose}
94
+ aria-label="Close"
79
95
  >
80
96
  <svg
81
97
  width="14"
@@ -100,7 +116,8 @@ export function AskAgentModal({
100
116
  onChange={(e) => setValue(e.target.value)}
101
117
  onKeyDown={(e) => {
102
118
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
103
- if (e.key === "Escape") onClose();
119
+ // Escape is handled at the document level by useDialogBehavior,
120
+ // guarded against discarding a dirty draft.
104
121
  }}
105
122
  />
106
123
  {contextPreview && (
@@ -1,6 +1,7 @@
1
- import { useState } from "react";
1
+ import { useRef, useState } from "react";
2
2
  import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
3
3
  import { copyTextToClipboard } from "../utils/clipboard";
4
+ import { useDialogBehavior } from "./ui/useDialogBehavior";
4
5
 
5
6
  export interface LintFinding {
6
7
  severity: "error" | "warning";
@@ -12,16 +13,28 @@ export interface LintFinding {
12
13
  export function LintModal({
13
14
  findings,
14
15
  projectId,
16
+ projectDir,
17
+ title = "HyperFrame Lint Results",
18
+ promptIntro = "Fix these HyperFrames lint issues",
15
19
  onClose,
16
20
  }: {
17
21
  findings: LintFinding[];
18
22
  projectId: string;
23
+ /** Real on-disk project directory for the agent prompt (not the browser URL). */
24
+ projectDir?: string | null;
25
+ /** Header subtitle — parameterize so console errors don't masquerade as lint results. */
26
+ title?: string;
27
+ /** First line of the copied agent prompt. */
28
+ promptIntro?: string;
19
29
  onClose: () => void;
20
30
  }) {
21
31
  const errors = findings.filter((f) => f.severity === "error");
22
32
  const warnings = findings.filter((f) => f.severity === "warning");
23
33
  const hasIssues = findings.length > 0;
24
34
  const [copied, setCopied] = useState(false);
35
+ const [copyFailed, setCopyFailed] = useState(false);
36
+ const containerRef = useRef<HTMLDivElement>(null);
37
+ const { requestClose } = useDialogBehavior({ open: true, onClose, containerRef });
25
38
 
26
39
  const handleCopyToAgent = async () => {
27
40
  const lines = findings.map((f) => {
@@ -30,21 +43,31 @@ export function LintModal({
30
43
  if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
31
44
  return line;
32
45
  });
33
- const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
46
+ const pathLine = projectDir ? `Project path: ${projectDir}\n\n` : "";
47
+ const text = `${promptIntro} for project "${projectId}":\n\n${pathLine}${lines.join("\n\n")}`;
34
48
  const copiedText = await copyTextToClipboard(text);
35
49
  if (copiedText) {
36
50
  setCopied(true);
51
+ setCopyFailed(false);
37
52
  setTimeout(() => setCopied(false), 2000);
53
+ } else {
54
+ setCopyFailed(true);
55
+ setTimeout(() => setCopyFailed(false), 3000);
38
56
  }
39
57
  };
40
58
 
41
59
  return (
42
60
  <div
43
- className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
44
- onClick={onClose}
61
+ className="hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
62
+ onClick={requestClose}
45
63
  >
46
64
  <div
47
- className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
65
+ ref={containerRef}
66
+ role="dialog"
67
+ aria-modal="true"
68
+ aria-label={title}
69
+ tabIndex={-1}
70
+ className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden outline-none"
48
71
  onClick={(e) => e.stopPropagation()}
49
72
  >
50
73
  {/* Header */}
@@ -65,12 +88,13 @@ export function LintModal({
65
88
  ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
66
89
  : "All checks passed"}
67
90
  </h2>
68
- <p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
91
+ <p className="text-xs text-neutral-500">{title}</p>
69
92
  </div>
70
93
  </div>
71
94
  <button
72
95
  onClick={onClose}
73
- className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
96
+ aria-label="Close"
97
+ className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors active:scale-[0.98]"
74
98
  >
75
99
  <XIcon size={16} />
76
100
  </button>
@@ -81,13 +105,19 @@ export function LintModal({
81
105
  <div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
82
106
  <button
83
107
  onClick={handleCopyToAgent}
84
- className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
108
+ className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors active:scale-[0.98] ${
85
109
  copied
86
110
  ? "bg-green-600 text-white"
87
- : "bg-studio-accent hover:bg-studio-accent/80 text-white"
111
+ : copyFailed
112
+ ? "bg-red-600 text-white"
113
+ : "bg-studio-accent hover:bg-studio-accent/80 text-white"
88
114
  }`}
89
115
  >
90
- {copied ? "Copied!" : "Copy to Agent"}
116
+ {copied
117
+ ? "Copied!"
118
+ : copyFailed
119
+ ? "Copy failed — check permissions"
120
+ : "Copy to Agent"}
91
121
  </button>
92
122
  </div>
93
123
  )}
@@ -1,8 +1,41 @@
1
+ import { useState } from "react";
1
2
  import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
2
3
 
4
+ function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) {
5
+ return (
6
+ <div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
7
+ <svg
8
+ width="40"
9
+ height="40"
10
+ viewBox="0 0 24 24"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ strokeWidth="1.5"
14
+ className="text-neutral-600"
15
+ aria-hidden="true"
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <line x1="12" y1="8" x2="12" y2="12" strokeLinecap="round" />
19
+ <line x1="12" y1="16" x2="12.01" y2="16" strokeLinecap="round" />
20
+ </svg>
21
+ <span className="text-sm text-neutral-400 font-medium">{name}</span>
22
+ <span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
23
+ <span className="text-[10px] text-neutral-500">
24
+ Couldn't load this file — it may be missing or corrupt
25
+ </span>
26
+ </div>
27
+ );
28
+ }
29
+
3
30
  export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
4
31
  const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
5
32
  const name = filePath.split("/").pop() ?? filePath;
33
+ // Keyed by path so switching to another file clears a previous failure.
34
+ const [failedPath, setFailedPath] = useState<string | null>(null);
35
+ const failed = failedPath === filePath;
36
+ const setFailed = () => setFailedPath(filePath);
37
+
38
+ if (failed) return <MediaErrorPanel name={name} filePath={filePath} />;
6
39
 
7
40
  if (IMAGE_EXT.test(filePath)) {
8
41
  return (
@@ -10,6 +43,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
10
43
  <img
11
44
  src={serveUrl}
12
45
  alt={name}
46
+ onError={setFailed}
13
47
  className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
14
48
  />
15
49
  <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
@@ -23,6 +57,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
23
57
  <video
24
58
  src={serveUrl}
25
59
  controls
60
+ onError={setFailed}
26
61
  className="max-w-full max-h-[70%] rounded border border-neutral-800"
27
62
  />
28
63
  <span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
@@ -46,7 +81,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
46
81
  <circle cx="6" cy="18" r="3" />
47
82
  <circle cx="18" cy="16" r="3" />
48
83
  </svg>
49
- <audio src={serveUrl} controls className="w-full max-w-[280px]" />
84
+ <audio src={serveUrl} controls onError={setFailed} className="w-full max-w-[280px]" />
50
85
  <span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
51
86
  </div>
52
87
  );
@@ -1,22 +1,23 @@
1
1
  interface SaveQueuePausedBannerProps {
2
2
  message: string;
3
- onDismiss: () => void;
3
+ /** Resets the save-queue circuit breaker so persistence resumes. */
4
+ onRetry: () => void;
4
5
  }
5
6
 
6
7
  /** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
7
- export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
8
+ export function SaveQueuePausedBanner({ message, onRetry }: SaveQueuePausedBannerProps) {
8
9
  return (
9
10
  <div
10
- className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
11
+ className="hf-backdrop-in absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
11
12
  role="alert"
12
13
  >
13
14
  <span>{message}</span>
14
15
  <button
15
16
  type="button"
16
- onClick={onDismiss}
17
- className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
17
+ onClick={onRetry}
18
+ className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10 active:scale-[0.98]"
18
19
  >
19
- Dismiss
20
+ Retry saving
20
21
  </button>
21
22
  </div>
22
23
  );
@@ -30,39 +30,27 @@ export class StudioErrorBoundary extends Component<Props, State> {
30
30
  if (!this.state.error) return this.props.children;
31
31
 
32
32
  return (
33
- <div
34
- style={{
35
- position: "fixed",
36
- inset: 0,
37
- display: "flex",
38
- flexDirection: "column",
39
- alignItems: "center",
40
- justifyContent: "center",
41
- background: "#0a0a0a",
42
- color: "#e5e5e5",
43
- fontFamily: "system-ui, -apple-system, sans-serif",
44
- gap: 16,
45
- }}
46
- >
47
- <div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
48
- <div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
33
+ <div className="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-neutral-950 font-sans text-neutral-200">
34
+ <div className="text-lg font-semibold">Something went wrong</div>
35
+ <div className="max-w-[480px] text-center text-[13px] text-neutral-500">
49
36
  {this.state.error.message}
50
37
  </div>
51
- <button
52
- onClick={() => this.setState({ error: null })}
53
- style={{
54
- marginTop: 8,
55
- padding: "8px 20px",
56
- background: "#2563eb",
57
- color: "#fff",
58
- border: "none",
59
- borderRadius: 6,
60
- fontSize: 14,
61
- cursor: "pointer",
62
- }}
63
- >
64
- Try again
65
- </button>
38
+ <div className="mt-2 flex items-center gap-2">
39
+ <button
40
+ onClick={() => this.setState({ error: null })}
41
+ className="rounded-md bg-studio-accent px-5 py-2 text-sm font-medium text-neutral-950 transition-[filter] hover:brightness-110 active:scale-[0.98]"
42
+ >
43
+ Try again
44
+ </button>
45
+ {/* If the error recurs immediately, "Try again" loops — a full reload
46
+ is the recovery path that always works. */}
47
+ <button
48
+ onClick={() => window.location.reload()}
49
+ className="rounded-md border border-neutral-700 px-5 py-2 text-sm font-medium text-neutral-300 transition-colors hover:bg-neutral-800 active:scale-[0.98]"
50
+ >
51
+ Reload Studio
52
+ </button>
53
+ </div>
66
54
  </div>
67
55
  );
68
56
  }
@@ -69,6 +69,7 @@ function markPrompted(): void {
69
69
  // fallow-ignore-next-line complexity
70
70
  export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
71
71
  const [visible, setVisible] = useState(false);
72
+ const [entered, setEntered] = useState(false);
72
73
  const [rating, setRating] = useState<number | null>(null);
73
74
  const [comment, setComment] = useState("");
74
75
  const [submitted, setSubmitted] = useState(false);
@@ -88,6 +89,14 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
88
89
  return () => clearTimeout(showTimer);
89
90
  }, []);
90
91
 
92
+ // Animate height in on entrance — appearing 3s after load, an instant 32px
93
+ // bar shoves the whole preview stack up mid-task.
94
+ useEffect(() => {
95
+ if (!visible) return;
96
+ const raf = requestAnimationFrame(() => setEntered(true));
97
+ return () => cancelAnimationFrame(raf);
98
+ }, [visible]);
99
+
91
100
  // Auto-dismiss timer — reset when user interacts (sets rating)
92
101
  useEffect(() => {
93
102
  if (!visible || rating !== null || submitted) return;
@@ -141,8 +150,8 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
141
150
  return (
142
151
  <div
143
152
  className={[
144
- "flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
145
- exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
153
+ "flex items-center gap-3 px-4 overflow-hidden border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300 motion-reduce:transition-none",
154
+ entered && !exiting ? "h-8 opacity-100" : "h-0 opacity-0 border-t-transparent",
146
155
  ].join(" ")}
147
156
  >
148
157
  {submitted ? (
@@ -1,6 +1,6 @@
1
1
  export function StudioGlobalDragOverlay() {
2
2
  return (
3
- <div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
3
+ <div className="hf-backdrop-in absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
4
4
  <div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
5
5
  <svg
6
6
  width="32"