fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts (#1964)

This commit is contained in:
Vance Ingalls
2026-07-06 16:56:06 -07:00
committed by GitHub
parent ccc1308839
commit 89e36da13d
23 changed files with 700 additions and 281 deletions
+13 -14
View File
@@ -27,6 +27,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
import { useLintModal } from "./hooks/useLintModal";
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
import { useToast } from "./hooks/useToast";
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import {
buildStudioContextValue,
@@ -132,7 +133,7 @@ export function StudioApp() {
return !v;
});
}, []);
const { appToast, showToast, dismissToast } = useToast();
const { toasts, showToast, dismissToast } = useToast();
const panelLayout = usePanelLayout({
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
@@ -389,17 +390,13 @@ export function StudioApp() {
},
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
);
const handleSelectComposition = useCallback(
(comp: string) => {
setActiveCompPath(comp.endsWith(".html") ? comp : null);
fileManager.setEditingFile({ path: comp, content: null });
fetch(`/api/projects/${projectId}/files/${comp}`)
.then((r) => r.json())
.then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
.catch(() => {});
},
[projectId, fileManager],
);
const { setEditingFile } = fileManager;
const handleSelectComposition = useCompositionContentLoader({
projectId,
setEditingFile,
setActiveCompPath,
showToast,
});
const {
designPanelActive,
inspectorPanelActive,
@@ -484,6 +481,7 @@ export function StudioApp() {
captureFrameFilename={frameCapture.captureFrameFilename}
handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
capturing={frameCapture.capturing}
inspectorButtonActive={inspectorButtonActive}
inspectorPanelActive={inspectorPanelActive}
onExport={() => {
@@ -496,7 +494,7 @@ export function StudioApp() {
{previewPersistence.domEditSaveQueuePaused && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}
{viewModeValue.viewMode === "storyboard" && (
@@ -578,6 +576,7 @@ export function StudioApp() {
</div>
<StudioOverlays
projectId={projectId}
projectDir={fileManager.projectDir}
lintModal={lintModal}
closeLintModal={closeLintModal}
consoleErrors={consoleErrors}
@@ -585,7 +584,7 @@ export function StudioApp() {
domEditSession={domEditSession}
activeCompPath={activeCompPath}
dragOverlayActive={dragOverlay.active}
appToast={appToast}
toasts={toasts}
dismissToast={dismissToast}
/>
</div>
@@ -1,6 +1,7 @@
import { useState, useRef, type CSSProperties } from "react";
import { useMountEffect } from "../hooks/useMountEffect";
import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers";
import { useDialogBehavior } from "./ui/useDialogBehavior";
function getAgentModalPositionStyle(
anchorPoint: AgentModalAnchorPoint | null,
@@ -39,7 +40,16 @@ export function AskAgentModal({
}) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
// A dirty draft vetoes Escape/backdrop closes — a stray click must not
// discard typed instructions. The X button and Copy still close directly.
const { requestClose } = useDialogBehavior({
open: true,
onClose,
containerRef,
canClose: () => !value.trim(),
});
useMountEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
@@ -54,13 +64,18 @@ export function AskAgentModal({
<div
className={
anchorPoint
? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
? "hf-backdrop-in fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
: "hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
}
onClick={onClose}
onClick={requestClose}
>
<div
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label="Copy prompt to AI agent"
tabIndex={-1}
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl outline-none ${
anchorPoint ? "fixed" : ""
}`}
style={modalPositionStyle}
@@ -74,8 +89,9 @@ export function AskAgentModal({
</p>
</div>
<button
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50 active:scale-[0.98]"
onClick={onClose}
aria-label="Close"
>
<svg
width="14"
@@ -100,7 +116,8 @@ export function AskAgentModal({
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
if (e.key === "Escape") onClose();
// Escape is handled at the document level by useDialogBehavior,
// guarded against discarding a dirty draft.
}}
/>
{contextPreview && (
+40 -10
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
import { copyTextToClipboard } from "../utils/clipboard";
import { useDialogBehavior } from "./ui/useDialogBehavior";
export interface LintFinding {
severity: "error" | "warning";
@@ -12,16 +13,28 @@ export interface LintFinding {
export function LintModal({
findings,
projectId,
projectDir,
title = "HyperFrame Lint Results",
promptIntro = "Fix these HyperFrames lint issues",
onClose,
}: {
findings: LintFinding[];
projectId: string;
/** Real on-disk project directory for the agent prompt (not the browser URL). */
projectDir?: string | null;
/** Header subtitle — parameterize so console errors don't masquerade as lint results. */
title?: string;
/** First line of the copied agent prompt. */
promptIntro?: string;
onClose: () => void;
}) {
const errors = findings.filter((f) => f.severity === "error");
const warnings = findings.filter((f) => f.severity === "warning");
const hasIssues = findings.length > 0;
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { requestClose } = useDialogBehavior({ open: true, onClose, containerRef });
const handleCopyToAgent = async () => {
const lines = findings.map((f) => {
@@ -30,21 +43,31 @@ export function LintModal({
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
return line;
});
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
const pathLine = projectDir ? `Project path: ${projectDir}\n\n` : "";
const text = `${promptIntro} for project "${projectId}":\n\n${pathLine}${lines.join("\n\n")}`;
const copiedText = await copyTextToClipboard(text);
if (copiedText) {
setCopied(true);
setCopyFailed(false);
setTimeout(() => setCopied(false), 2000);
} else {
setCopyFailed(true);
setTimeout(() => setCopyFailed(false), 3000);
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
className="hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={requestClose}
>
<div
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"
ref={containerRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
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"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
@@ -65,12 +88,13 @@ export function LintModal({
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
: "All checks passed"}
</h2>
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
<p className="text-xs text-neutral-500">{title}</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
aria-label="Close"
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors active:scale-[0.98]"
>
<XIcon size={16} />
</button>
@@ -81,13 +105,19 @@ export function LintModal({
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
<button
onClick={handleCopyToAgent}
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors active:scale-[0.98] ${
copied
? "bg-green-600 text-white"
: "bg-studio-accent hover:bg-studio-accent/80 text-white"
: copyFailed
? "bg-red-600 text-white"
: "bg-studio-accent hover:bg-studio-accent/80 text-white"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
{copied
? "Copied!"
: copyFailed
? "Copy failed — check permissions"
: "Copy to Agent"}
</button>
</div>
)}
@@ -1,8 +1,41 @@
import { useState } from "react";
import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) {
return (
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
<svg
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-600"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" strokeLinecap="round" />
<line x1="12" y1="16" x2="12.01" y2="16" strokeLinecap="round" />
</svg>
<span className="text-sm text-neutral-400 font-medium">{name}</span>
<span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
<span className="text-[10px] text-neutral-500">
Couldn't load this file it may be missing or corrupt
</span>
</div>
);
}
export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
const name = filePath.split("/").pop() ?? filePath;
// Keyed by path so switching to another file clears a previous failure.
const [failedPath, setFailedPath] = useState<string | null>(null);
const failed = failedPath === filePath;
const setFailed = () => setFailedPath(filePath);
if (failed) return <MediaErrorPanel name={name} filePath={filePath} />;
if (IMAGE_EXT.test(filePath)) {
return (
@@ -10,6 +43,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
<img
src={serveUrl}
alt={name}
onError={setFailed}
className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
/>
<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
<video
src={serveUrl}
controls
onError={setFailed}
className="max-w-full max-h-[70%] rounded border border-neutral-800"
/>
<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
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
<audio src={serveUrl} controls className="w-full max-w-[280px]" />
<audio src={serveUrl} controls onError={setFailed} className="w-full max-w-[280px]" />
<span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
</div>
);
@@ -1,22 +1,23 @@
interface SaveQueuePausedBannerProps {
message: string;
onDismiss: () => void;
/** Resets the save-queue circuit breaker so persistence resumes. */
onRetry: () => void;
}
/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
export function SaveQueuePausedBanner({ message, onRetry }: SaveQueuePausedBannerProps) {
return (
<div
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"
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"
role="alert"
>
<span>{message}</span>
<button
type="button"
onClick={onDismiss}
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
onClick={onRetry}
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]"
>
Dismiss
Retry saving
</button>
</div>
);
@@ -30,39 +30,27 @@ export class StudioErrorBoundary extends Component<Props, State> {
if (!this.state.error) return this.props.children;
return (
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#0a0a0a",
color: "#e5e5e5",
fontFamily: "system-ui, -apple-system, sans-serif",
gap: 16,
}}
>
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
<div className="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-neutral-950 font-sans text-neutral-200">
<div className="text-lg font-semibold">Something went wrong</div>
<div className="max-w-[480px] text-center text-[13px] text-neutral-500">
{this.state.error.message}
</div>
<button
onClick={() => this.setState({ error: null })}
style={{
marginTop: 8,
padding: "8px 20px",
background: "#2563eb",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
cursor: "pointer",
}}
>
Try again
</button>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => this.setState({ error: null })}
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]"
>
Try again
</button>
{/* If the error recurs immediately, "Try again" loops — a full reload
is the recovery path that always works. */}
<button
onClick={() => window.location.reload()}
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]"
>
Reload Studio
</button>
</div>
</div>
);
}
@@ -69,6 +69,7 @@ function markPrompted(): void {
// fallow-ignore-next-line complexity
export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
const [visible, setVisible] = useState(false);
const [entered, setEntered] = useState(false);
const [rating, setRating] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
@@ -88,6 +89,14 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
return () => clearTimeout(showTimer);
}, []);
// Animate height in on entrance — appearing 3s after load, an instant 32px
// bar shoves the whole preview stack up mid-task.
useEffect(() => {
if (!visible) return;
const raf = requestAnimationFrame(() => setEntered(true));
return () => cancelAnimationFrame(raf);
}, [visible]);
// Auto-dismiss timer — reset when user interacts (sets rating)
useEffect(() => {
if (!visible || rating !== null || submitted) return;
@@ -141,8 +150,8 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
return (
<div
className={[
"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",
exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
"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",
entered && !exiting ? "h-8 opacity-100" : "h-0 opacity-0 border-t-transparent",
].join(" ")}
>
{submitted ? (
@@ -1,6 +1,6 @@
export function StudioGlobalDragOverlay() {
return (
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<div className="hf-backdrop-in absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
<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]">
<svg
width="32"
+187 -109
View File
@@ -1,4 +1,4 @@
import type { MouseEvent } from "react";
import { useRef, type MouseEvent } from "react";
import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
import {
STUDIO_INSPECTOR_PANELS_ENABLED,
@@ -9,12 +9,14 @@ import { useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { Tooltip } from "./ui";
export interface StudioHeaderProps {
captureFrameHref: string;
captureFrameFilename: string;
handleCaptureFrameClick: (event: MouseEvent<HTMLAnchorElement>) => void;
refreshCaptureFrameTime: () => void;
capturing?: boolean;
inspectorButtonActive: boolean;
inspectorPanelActive: boolean;
onExport?: () => void;
@@ -149,26 +151,45 @@ const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
/** Segmented control switching the main stage between storyboard and preview. */
function ViewModeToggle() {
const { viewMode, setViewMode } = useViewMode();
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
const selectMode = (mode: StudioViewMode) => {
if (mode === viewMode) return;
trackStudioEvent("view_mode_toggle", { mode });
setViewMode(mode);
};
// Complete APG tabs pattern: roving tabIndex + arrow-key navigation.
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const dir = e.key === "ArrowLeft" ? -1 : 1;
const next = (index + dir + VIEW_MODE_OPTIONS.length) % VIEW_MODE_OPTIONS.length;
tabRefs.current[next]?.focus();
selectMode(VIEW_MODE_OPTIONS[next].mode);
};
return (
<div
className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
role="tablist"
aria-label="Studio view"
>
{VIEW_MODE_OPTIONS.map(({ mode, label }) => {
{VIEW_MODE_OPTIONS.map(({ mode, label }, index) => {
const active = viewMode === mode;
return (
<button
key={mode}
ref={(el) => {
tabRefs.current[index] = el;
}}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
if (active) return;
trackStudioEvent("view_mode_toggle", { mode });
setViewMode(mode);
}}
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
tabIndex={active ? 0 : -1}
onClick={() => selectMode(mode)}
onKeyDown={(e) => handleKeyDown(e, index)}
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors active:scale-[0.98] outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
}`}
>
@@ -186,12 +207,14 @@ export function StudioHeader({
captureFrameFilename,
handleCaptureFrameClick,
refreshCaptureFrameTime,
capturing,
inspectorButtonActive,
inspectorPanelActive,
onExport,
}: StudioHeaderProps) {
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
const isRendering = renderQueue.isRendering;
return (
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
@@ -207,118 +230,173 @@ export function StudioHeader({
<ViewModeToggle />
{/* Right: toolbar buttons */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
trackStudioEvent("toolbar_action", { action: "undo" });
void handleUndo();
}}
disabled={!editHistory.canUndo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canUndo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
title={
<Tooltip
label={
editHistory.undoLabel
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
: `Undo (${getHistoryShortcutLabel("undo")})`
}
aria-label="Undo"
side="bottom"
>
<RotateCcw size={14} />
</button>
<button
type="button"
onClick={() => {
trackStudioEvent("toolbar_action", { action: "redo" });
void handleRedo();
}}
disabled={!editHistory.canRedo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canRedo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
title={
<button
type="button"
onClick={() => {
trackStudioEvent("toolbar_action", { action: "undo" });
void handleUndo();
}}
disabled={!editHistory.canUndo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
editHistory.canUndo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
aria-label="Undo"
>
<RotateCcw size={14} />
</button>
</Tooltip>
<Tooltip
label={
editHistory.redoLabel
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
: `Redo (${getHistoryShortcutLabel("redo")})`
}
aria-label="Redo"
side="bottom"
>
<RotateCw size={14} />
</button>
<a
href={captureFrameHref}
download={captureFrameFilename}
onClick={(e) => {
trackStudioEvent("toolbar_action", { action: "capture_frame" });
handleCaptureFrameClick(e);
}}
onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime}
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800"
title="Capture current frame"
aria-label="Capture current frame"
>
<Camera size={14} />
<span>Capture</span>
</a>
<button
type="button"
onClick={() => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (rightCollapsed || !inspectorPanelActive) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design");
setRightCollapsed(false);
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
// Keep the current selection when collapsing the Inspector — closing
// the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
inspectorButtonActive
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: STUDIO_INSPECTOR_PANELS_ENABLED
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
: "cursor-not-allowed border-transparent text-neutral-700"
}`}
title={
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
}
aria-label={
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
<button
type="button"
onClick={() => {
trackStudioEvent("toolbar_action", { action: "redo" });
void handleRedo();
}}
disabled={!editHistory.canRedo}
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
editHistory.canRedo
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
: "text-neutral-700 cursor-default"
}`}
aria-label="Redo"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
</svg>
Inspector
</button>
<button
type="button"
onClick={() => {
setRightPanelTab("renders");
setRightCollapsed(false);
onExport?.();
}}
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors"
<RotateCw size={14} />
</button>
</Tooltip>
<Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
<a
href={captureFrameHref}
download={captureFrameFilename}
onClick={(e) => {
if (capturing) {
e.preventDefault();
return;
}
trackStudioEvent("toolbar_action", { action: "capture_frame" });
handleCaptureFrameClick(e);
}}
onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime}
aria-disabled={capturing || undefined}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${
capturing
? "text-neutral-600 cursor-default"
: "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]"
}`}
aria-label={capturing ? "Capturing frame" : "Capture current frame"}
>
{capturing ? (
<svg
className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : (
<Camera size={14} />
)}
<span>{capturing ? "Capturing…" : "Capture"}</span>
</a>
</Tooltip>
<Tooltip
label={
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
}
side="bottom"
>
Export
</button>
<button
type="button"
onClick={() => {
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
if (rightCollapsed || !inspectorPanelActive) {
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
setRightPanelTab("design");
setRightCollapsed(false);
return;
}
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
// Keep the current selection when collapsing the Inspector — closing
// the panel shouldn't deselect the element.
setRightCollapsed(true);
}}
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
aria-pressed={inspectorButtonActive}
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors active:scale-[0.98] ${
inspectorButtonActive
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: STUDIO_INSPECTOR_PANELS_ENABLED
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
: "cursor-not-allowed border-transparent text-neutral-700"
}`}
aria-label={
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
</svg>
Inspector
</button>
</Tooltip>
<Tooltip
label={
isRendering ? "A render is already in progress" : "Render and export this composition"
}
side="bottom"
>
<button
type="button"
disabled={isRendering}
onClick={() => {
if (isRendering) return;
setRightPanelTab("renders");
setRightCollapsed(false);
onExport?.();
}}
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
>
{isRendering ? "Rendering…" : "Export"}
</button>
</Tooltip>
</div>
</div>
);
@@ -34,6 +34,7 @@ export function StudioLeftSidebar({
const {
leftCollapsed,
leftWidth,
setLeftWidth,
toggleLeftSidebar,
handlePanelResizeStart,
handlePanelResizeMove,
@@ -114,14 +115,22 @@ export function StudioLeftSidebar({
onRenameFile={handleRenameFile}
onDuplicateFile={handleDuplicateFile}
onMoveFile={handleMoveFile}
onImportFiles={handleImportFiles}
onImportFiles={async (files, dir) => {
await handleImportFiles(files, dir);
}}
codeChildren={
editingFile ? (
isMediaFile(editingFile.path) ? (
<MediaPreview projectId={projectId} filePath={editingFile.path} />
) : editingFile.content == null ? (
// Never mount the editor on unloaded content: a keystroke would
// autosave an empty document over the real file.
<div className="flex h-full items-center justify-center text-[11px] text-neutral-600">
Loading {editingFile.path}
</div>
) : (
<SourceEditor
content={editingFile.content ?? ""}
content={editingFile.content}
filePath={editingFile.path}
onChange={handleContentChange}
revealOffset={revealSourceOffset}
@@ -140,11 +149,23 @@ export function StudioLeftSidebar({
onPreviewBlock={onPreviewBlock}
/>
<div
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
role="separator"
aria-label="Resize sidebar"
aria-orientation="vertical"
tabIndex={0}
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
style={{ touchAction: "none" }}
onPointerDown={(e) => handlePanelResizeStart("left", e)}
onPointerMove={handlePanelResizeMove}
onPointerUp={handlePanelResizeEnd}
onPointerCancel={handlePanelResizeEnd}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
const delta = e.key === "ArrowLeft" ? -16 : 16;
const maxLeft = Math.floor(window.innerWidth * 0.5);
setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta)));
}}
>
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
@@ -11,6 +11,7 @@ type LintFindings = ComponentProps<typeof LintModal>["findings"];
export interface StudioOverlaysProps {
projectId: string;
projectDir?: string | null;
lintModal: LintFindings | null;
closeLintModal: () => void;
consoleErrors: LintFindings | null;
@@ -18,8 +19,8 @@ export interface StudioOverlaysProps {
domEditSession: ReturnType<typeof useDomEditSession>;
activeCompPath: string | null;
dragOverlayActive: boolean;
appToast: ReturnType<typeof useToast>["appToast"];
dismissToast: () => void;
toasts: ReturnType<typeof useToast>["toasts"];
dismissToast: (id: number) => void;
}
/**
@@ -30,6 +31,7 @@ export interface StudioOverlaysProps {
// fallow-ignore-next-line complexity
export function StudioOverlays({
projectId,
projectDir,
lintModal,
closeLintModal,
consoleErrors,
@@ -37,16 +39,30 @@ export function StudioOverlays({
domEditSession,
activeCompPath,
dragOverlayActive,
appToast,
toasts,
dismissToast,
}: StudioOverlaysProps) {
return (
<>
{lintModal !== null && (
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
<LintModal
findings={lintModal}
projectId={projectId}
projectDir={projectDir}
onClose={closeLintModal}
/>
)}
{consoleErrors !== null && consoleErrors.length > 0 && (
<LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
{/* One modal at a time — console errors wait behind an open lint modal
instead of stacking two full-screen overlays. */}
{lintModal === null && consoleErrors !== null && consoleErrors.length > 0 && (
<LintModal
findings={consoleErrors}
projectId={projectId}
projectDir={projectDir}
title="Console errors in preview"
promptIntro="Fix these runtime console errors from the composition preview"
onClose={clearConsoleErrors}
/>
)}
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
<AskAgentModal
@@ -62,8 +78,18 @@ export function StudioOverlays({
/>
)}
{dragOverlayActive && <StudioGlobalDragOverlay />}
{appToast && (
<StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
{toasts.length > 0 && (
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
{toasts.map((toast) => (
<StudioToast
key={toast.id}
message={toast.message}
tone={toast.tone}
leaving={toast.leaving}
onDismiss={() => dismissToast(toast.id)}
/>
))}
</div>
)}
</>
);
@@ -77,6 +77,7 @@ export function StudioRightPanel({
}: StudioRightPanelProps) {
const {
rightWidth,
setRightWidth,
rightPanelTab,
setRightPanelTab,
rightInspectorPanes,
@@ -400,6 +401,11 @@ export function StudioRightPanel({
jobs={renderJobs}
projectId={projectId}
onDelete={renderQueue.deleteRender}
onCancel={renderQueue.cancelRender}
loadError={renderQueue.loadError}
onRetryLoad={renderQueue.reloadRenders}
actionError={renderQueue.actionError}
onDismissActionError={renderQueue.dismissActionError}
onClearCompleted={renderQueue.clearCompleted}
onStartRender={async (format, quality, resolution, fps) => {
await waitForPendingDomEditSaves();
@@ -421,11 +427,23 @@ export function StudioRightPanel({
return (
<>
<div
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
role="separator"
aria-label="Resize inspector panel"
aria-orientation="vertical"
tabIndex={0}
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
style={{ touchAction: "none" }}
onPointerDown={(e) => handlePanelResizeStart("right", e)}
onPointerMove={handlePanelResizeMove}
onPointerUp={handlePanelResizeEnd}
onPointerCancel={handlePanelResizeEnd}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
const delta = e.key === "ArrowLeft" ? 16 : -16;
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
}}
>
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
@@ -444,7 +462,8 @@ export function StudioRightPanel({
<button
type="button"
onClick={() => handleInspectorPaneButtonClick("design")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
aria-pressed={designPaneOpen}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
designPaneOpen
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -457,7 +476,8 @@ export function StudioRightPanel({
<button
type="button"
onClick={() => handleInspectorPaneButtonClick("layers")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
aria-pressed={layersPaneOpen}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
layersPaneOpen
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -472,7 +492,8 @@ export function StudioRightPanel({
<button
type="button"
onClick={() => setRightPanelTab("renders")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
aria-pressed={rightPanelTab === "renders"}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
rightPanelTab === "renders"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -485,7 +506,8 @@ export function StudioRightPanel({
<button
type="button"
onClick={() => setRightPanelTab("slideshow")}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
aria-pressed={rightPanelTab === "slideshow"}
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
rightPanelTab === "slideshow"
? "bg-neutral-800 text-white"
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -537,6 +559,24 @@ export function StudioRightPanel({
<LayersPanel />
) : designPaneOpen ? (
propertyPanel
) : inspectorTabActive ? (
// Inspector tab selected but no pane can render (panes toggled
// off, or inspector inactive during playback/recording): show an
// explanation instead of silently rendering the render queue
// under a highlighted inspector tab.
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-xs text-neutral-500">
Inspector is unavailable right now select the Design or Layers pane above, or
pause playback/recording to inspect elements.
</p>
<button
type="button"
onClick={() => setRightPanelTab("renders")}
className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
>
Show Renders
</button>
</div>
) : (
renderQueuePanel
)}
@@ -2,15 +2,18 @@ export function StudioSplash({ waiting }: { waiting?: boolean }) {
return (
<div className="h-full w-full bg-neutral-950 flex items-center justify-center">
{waiting ? (
<div className="flex flex-col items-center gap-3 text-center px-6">
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin" />
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin motion-reduce:animate-none" />
<p className="text-xs text-neutral-600">
Waiting for preview server run{" "}
<code className="text-neutral-500 font-mono">npm run dev</code>
</p>
</div>
) : (
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse motion-reduce:animate-none" />
<p className="text-xs text-neutral-600">Connecting to project</p>
</div>
)}
</div>
);
+6 -10
View File
@@ -1,18 +1,17 @@
interface StudioToastProps {
message: string;
tone?: "error" | "info";
/** Plays the exit animation when true (owner removes the node after ~160ms). */
leaving?: boolean;
onDismiss?: () => void;
}
// fallow-ignore-next-line complexity
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastProps) {
const isError = tone === "error";
return (
<div
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
onClick={onDismiss}
role={onDismiss ? "button" : undefined}
style={onDismiss ? { cursor: "pointer" } : undefined}
role={isError ? "alert" : "status"}
className={`motion-reduce:animate-none ${leaving ? "hf-toast-exit" : "hf-toast-enter"}`}
>
<div
className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
@@ -38,10 +37,7 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
{onDismiss && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
onClick={onDismiss}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
aria-label="Dismiss"
>
@@ -30,21 +30,27 @@ function renderToolbar() {
describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
it("renders enabled (pressed) by default with no selection", () => {
const { host, root } = renderToolbar();
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
const btn = host.querySelector<HTMLButtonElement>(
'button[aria-label="Auto-record manual edits as keyframes"]',
);
expect(btn).not.toBeNull();
expect(btn?.getAttribute("aria-pressed")).toBe("true");
act(() => root.unmount());
});
it("flips autoKeyframeEnabled in the store when clicked", () => {
const { host, root } = renderToolbar();
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
const btn = host.querySelector<HTMLButtonElement>(
'button[aria-label="Auto-record manual edits as keyframes"]',
);
if (!btn) throw new Error("auto-keyframe toggle not rendered");
act(() => {
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
expect(btn.getAttribute("aria-pressed")).toBe("false");
act(() => root.unmount());
});
});
@@ -112,7 +112,9 @@ export function TimelineToolbar({
<button
type="button"
onClick={() => setActiveTool("select")}
className={`flex h-6 w-6 items-center justify-center transition-colors ${
aria-label="Selection tool"
aria-pressed={activeTool === "select"}
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
activeTool === "select"
? "bg-neutral-700 text-neutral-200"
: "text-neutral-500 hover:text-neutral-300"
@@ -123,11 +125,13 @@ export function TimelineToolbar({
</svg>
</button>
</Tooltip>
<Tooltip label="Razor tool (B)">
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
<button
type="button"
onClick={() => setActiveTool("razor")}
className={`flex h-6 w-6 items-center justify-center transition-colors ${
aria-label="Razor tool"
aria-pressed={activeTool === "razor"}
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
activeTool === "razor"
? "bg-neutral-700 text-neutral-200"
: "text-neutral-500 hover:text-neutral-300"
@@ -153,7 +157,12 @@ export function TimelineToolbar({
<button
type="button"
onClick={onToggleKeyframe}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
aria-label={
keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
keyframeState === "active"
? "text-studio-accent"
: keyframeState === "inactive"
@@ -187,8 +196,9 @@ export function TimelineToolbar({
<button
type="button"
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
aria-label="Auto-record manual edits as keyframes"
aria-pressed={autoKeyframeEnabled}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
autoKeyframeEnabled
? "text-red-400 hover:text-red-300"
: "text-neutral-600 hover:text-neutral-400"
@@ -213,23 +223,35 @@ export function TimelineToolbar({
)}
{onSplitElement &&
(() => {
// Render the button unconditionally (disabled when unusable):
// mounting/unmounting mid-task shifts the neighboring controls.
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
const el = selectedElementId
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
: null;
if (!el || !canSplitElement(el)) return null;
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
const splittable = el != null && canSplitElement(el);
const canSplit =
splittable && currentTime > el.start && currentTime < el.start + el.duration;
return (
<Tooltip label="Split clip at playhead (S)">
<Tooltip
label={
canSplit
? "Split clip at playhead (S)"
: splittable
? "Move the playhead inside the clip to split"
: "Select a clip to split"
}
>
<button
type="button"
disabled={!canSplit}
aria-label="Split clip at playhead"
onClick={() => {
if (canSplit) onSplitElement(el, currentTime);
if (canSplit && el) onSplitElement(el, currentTime);
}}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
canSplit
? "text-neutral-500 hover:text-neutral-200"
? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
: "text-neutral-700 cursor-not-allowed"
}`}
>
@@ -239,26 +261,38 @@ export function TimelineToolbar({
);
})()}
{beatAnalysisReady &&
canAddBeatAt(currentTime) &&
(() => (
<Tooltip label="Add beat at playhead">
<button
type="button"
onClick={() => addBeatAtCompositionTime(currentTime)}
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-[#22c55e]"
(() => {
const canAdd = canAddBeatAt(currentTime);
return (
<Tooltip
label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Tooltip>
))()}
<button
type="button"
disabled={!canAdd}
aria-label="Add beat at playhead"
onClick={() => {
if (canAdd) addBeatAtCompositionTime(currentTime);
}}
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
canAdd
? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
: "text-neutral-700 cursor-not-allowed"
}`}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-1">
<Tooltip label="Fit timeline to width">
@@ -16,6 +16,7 @@ export function PanelLayoutProvider({
leftWidth,
setLeftWidth,
rightWidth,
setRightWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
@@ -39,6 +40,7 @@ export function PanelLayoutProvider({
leftWidth,
setLeftWidth,
rightWidth,
setRightWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
@@ -56,6 +58,7 @@ export function PanelLayoutProvider({
leftWidth,
setLeftWidth,
rightWidth,
setRightWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
@@ -0,0 +1,40 @@
import { useCallback } from "react";
/**
* Loads a composition file's content for the source editor when a composition
* is selected. Content stays null until the fetch resolves — the source editor
* must not mount on a null-content file, or its autosave would overwrite the
* real file with an empty document. Load failures surface as an error toast
* instead of silently rendering an empty (and autosave-armed) editor.
*/
export function useCompositionContentLoader({
projectId,
setEditingFile,
setActiveCompPath,
showToast,
}: {
projectId: string | null;
setEditingFile: (file: { path: string; content: string | null }) => void;
setActiveCompPath: (path: string | null) => void;
showToast: (message: string, tone?: "error" | "info") => void;
}) {
return useCallback(
(comp: string) => {
setActiveCompPath(comp.endsWith(".html") ? comp : null);
setEditingFile({ path: comp, content: null });
fetch(`/api/projects/${projectId}/files/${comp}`)
.then(async (r) => {
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
return r.json();
})
.then((data: { content?: string }) => {
if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
setEditingFile({ path: comp, content: data.content });
})
.catch((err) => {
showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
});
},
[projectId, setEditingFile, setActiveCompPath, showToast],
);
}
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
recordEdit: (input: RecordEditInput) => Promise<void>;
domEditSaveTimestampRef: React.MutableRefObject<number>;
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
showToast: (message: string, tone?: "error" | "info") => void;
}
export function useEditorSave({
@@ -28,9 +29,13 @@ export function useEditorSave({
recordEdit,
domEditSaveTimestampRef,
setRefreshKey,
showToast,
}: UseEditorSaveOptions) {
const saveRafRef = useRef<number | null>(null);
const refreshRafRef = useRef<number | null>(null);
// One error toast per burst of failures — every keystroke retries the save,
// and error toasts persist until dismissed, so don't stack duplicates.
const lastFailureToastAtRef = useRef(0);
const handleContentChange = useCallback(
(content: string) => {
@@ -61,6 +66,14 @@ export function useEditorSave({
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
});
});
},
@@ -71,6 +84,7 @@ export function useEditorSave({
readProjectFile,
recordEdit,
setRefreshKey,
showToast,
writeProjectFile,
],
);
+39 -25
View File
@@ -126,6 +126,7 @@ export function useFileManager({
recordEdit,
domEditSaveTimestampRef,
setRefreshKey,
showToast,
});
// ── File select ──
@@ -133,26 +134,34 @@ export function useFileManager({
const revealRequestIdRef = useRef(0);
const revealAbortRef = useRef<AbortController | null>(null);
const handleFileSelect = useCallback((path: string) => {
const pid = projectIdRef.current;
if (!pid) return;
revealAbortRef.current?.abort();
revealAbortRef.current = null;
revealRequestIdRef.current++;
// Skip fetching binary content for media files — just set the path for preview
if (isMediaFile(path)) {
setEditingFile({ path, content: null });
return;
}
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
.then((r) => r.json())
.then((data: { content?: string }) => {
if (data.content != null) {
setEditingFile({ path, content: data.content });
}
})
.catch(() => {});
}, []);
const handleFileSelect = useCallback(
(path: string) => {
const pid = projectIdRef.current;
if (!pid) return;
revealAbortRef.current?.abort();
revealAbortRef.current = null;
revealRequestIdRef.current++;
// Skip fetching binary content for media files — just set the path for preview
if (isMediaFile(path)) {
setEditingFile({ path, content: null });
return;
}
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
.then((r) => {
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
return r.json();
})
.then((data: { content?: string }) => {
if (data.content != null) {
setEditingFile({ path, content: data.content });
}
})
.catch((err: unknown) => {
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
});
},
[showToast],
);
// ── Click-to-source ──
@@ -253,9 +262,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create file failed: ${err.error}`);
showToast(`Couldn't create ${path}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect],
[refreshFileTree, handleFileSelect, showToast],
);
const handleCreateFolder = useCallback(
@@ -275,9 +285,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Create folder failed: ${err.error}`);
showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
}
},
[refreshFileTree],
[refreshFileTree, showToast],
);
const handleDeleteFile = useCallback(
@@ -293,9 +304,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Delete failed: ${err.error}`);
showToast(`Couldn't delete ${path}: ${err.error}`, "error");
}
},
[refreshFileTree],
[refreshFileTree, showToast],
);
const handleRenameFile = useCallback(
@@ -316,9 +328,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Rename failed: ${err.error}`);
showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect, setRefreshKey],
[refreshFileTree, handleFileSelect, setRefreshKey, showToast],
);
const handleDuplicateFile = useCallback(
@@ -337,9 +350,10 @@ export function useFileManager({
} else {
const err = await res.json().catch(() => ({ error: "unknown" }));
console.error(`Duplicate failed: ${err.error}`);
showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
}
},
[refreshFileTree, handleFileSelect],
[refreshFileTree, handleFileSelect, showToast],
);
const handleMoveFile = handleRenameFile;
+12 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, type MouseEvent } from "react";
import { useState, useCallback, useRef, type MouseEvent } from "react";
import { useMountEffect } from "./useMountEffect";
import { liveTime, usePlayerStore } from "../player";
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
@@ -17,6 +17,8 @@ export function useFrameCapture({
waitForPendingDomEditSaves,
}: UseFrameCaptureParams) {
const [captureFrameTime, setCaptureFrameTime] = useState(0);
const [capturing, setCapturing] = useState(false);
const capturingRef = useRef(false);
useMountEffect(() => {
setCaptureFrameTime(usePlayerStore.getState().currentTime);
@@ -31,6 +33,11 @@ export function useFrameCapture({
async (event: MouseEvent<HTMLAnchorElement>) => {
if (!projectId) return;
event.preventDefault();
// A capture can take up to ~35s (save drain + server render) — ignore
// re-entrant clicks instead of firing parallel captures.
if (capturingRef.current) return;
capturingRef.current = true;
setCapturing(true);
try {
const time = usePlayerStore.getState().currentTime;
setCaptureFrameTime(time);
@@ -79,6 +86,9 @@ export function useFrameCapture({
}
} catch (err) {
showToast(err instanceof Error ? err.message : "Capture failed", "error");
} finally {
capturingRef.current = false;
setCapturing(false);
}
},
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
@@ -98,5 +108,6 @@ export function useFrameCapture({
captureFrameFilename,
handleCaptureFrameClick,
refreshCaptureFrameTime,
capturing,
};
}
@@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
leftWidth,
setLeftWidth,
rightWidth,
setRightWidth,
leftCollapsed,
setLeftCollapsed,
rightCollapsed,
+67 -14
View File
@@ -2,24 +2,77 @@ import { useState, useCallback, useRef } from "react";
import { useMountEffect } from "./useMountEffect";
import type { AppToast } from "../utils/studioHelpers";
export function useToast() {
const [appToast, setAppToast] = useState<AppToast | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
interface ToastItem extends AppToast {
id: number;
/** True while the exit animation plays, just before removal. */
leaving?: boolean;
}
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
if (timerRef.current) clearTimeout(timerRef.current);
setAppToast({ message, tone });
timerRef.current = setTimeout(() => setAppToast(null), 4000);
const AUTO_DISMISS_MS = 4000;
const EXIT_MS = 160;
const MAX_TOASTS = 3;
let nextToastId = 1;
/**
* Stacked toasts (max 3). Info toasts auto-dismiss after 4s; error toasts
* persist until explicitly dismissed so failures can't silently vanish.
*/
export function useToast() {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const clearTimer = useCallback((id: number) => {
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(id);
}
}, []);
const removeToast = useCallback(
(id: number) => {
clearTimer(id);
setToasts((prev) => prev.filter((t) => t.id !== id));
},
[clearTimer],
);
const dismissToast = useCallback(
(id: number) => {
clearTimer(id);
// Mark leaving so the exit animation plays, then remove.
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
const timer = setTimeout(() => removeToast(id), EXIT_MS);
timersRef.current.set(id, timer);
},
[clearTimer, removeToast],
);
const showToast = useCallback(
(message: string, tone: AppToast["tone"] = "error") => {
const id = nextToastId++;
setToasts((prev) => {
const next = [...prev, { id, message, tone }];
// Cap the stack; drop the oldest (and its pending timer).
while (next.length > MAX_TOASTS) {
const dropped = next.shift();
if (dropped) clearTimer(dropped.id);
}
return next;
});
if (tone !== "error") {
const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
timersRef.current.set(id, timer);
}
},
[clearTimer, dismissToast],
);
useMountEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
for (const timer of timersRef.current.values()) clearTimeout(timer);
timersRef.current.clear();
});
const dismissToast = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setAppToast(null);
}, []);
return { appToast, showToast, dismissToast };
return { toasts, showToast, dismissToast };
}