Skip to content

Commit d89e8e4

Browse files
committed
fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts
1 parent b143798 commit d89e8e4

23 files changed

Lines changed: 693 additions & 276 deletions

packages/studio/src/App.tsx

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
2727
import { useLintModal } from "./hooks/useLintModal";
2828
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
2929
import { useToast } from "./hooks/useToast";
30+
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
3031
import { useStudioUrlState } from "./hooks/useStudioUrlState";
3132
import {
3233
buildStudioContextValue,
@@ -133,7 +134,7 @@ export function StudioApp() {
133134
return !v;
134135
});
135136
}, []);
136-
const { appToast, showToast, dismissToast } = useToast();
137+
const { toasts, showToast, dismissToast } = useToast();
137138
const panelLayout = usePanelLayout({
138139
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
139140
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
@@ -390,17 +391,13 @@ export function StudioApp() {
390391
},
391392
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
392393
);
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-
);
394+
const { setEditingFile } = fileManager;
395+
const handleSelectComposition = useCompositionContentLoader({
396+
projectId,
397+
setEditingFile,
398+
setActiveCompPath,
399+
showToast,
400+
});
404401
const {
405402
designPanelActive,
406403
inspectorPanelActive,
@@ -485,14 +482,15 @@ export function StudioApp() {
485482
captureFrameFilename={frameCapture.captureFrameFilename}
486483
handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
487484
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
485+
capturing={frameCapture.capturing}
488486
inspectorButtonActive={inspectorButtonActive}
489487
inspectorPanelActive={inspectorPanelActive}
490488
onExport={() => void renderQueue.startRender(undefined)}
491489
/>
492490
{previewPersistence.domEditSaveQueuePaused && (
493491
<SaveQueuePausedBanner
494492
message={previewPersistence.domEditSaveQueuePaused}
495-
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
493+
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
496494
/>
497495
)}
498496
{viewModeValue.viewMode === "storyboard" && (
@@ -576,14 +574,15 @@ export function StudioApp() {
576574
</div>
577575
<StudioOverlays
578576
projectId={projectId}
577+
projectDir={fileManager.projectDir}
579578
lintModal={lintModal}
580579
closeLintModal={closeLintModal}
581580
consoleErrors={consoleErrors}
582581
clearConsoleErrors={() => setConsoleErrors(null)}
583582
domEditSession={domEditSession}
584583
activeCompPath={activeCompPath}
585584
dragOverlayActive={dragOverlay.active}
586-
appToast={appToast}
585+
toasts={toasts}
587586
dismissToast={dismissToast}
588587
/>
589588
</div>

packages/studio/src/components/AskAgentModal.tsx

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useRef, type CSSProperties } from "react";
22
import { useMountEffect } from "../hooks/useMountEffect";
33
import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers";
4+
import { useDialogBehavior } from "./ui/useDialogBehavior";
45

56
function getAgentModalPositionStyle(
67
anchorPoint: AgentModalAnchorPoint | null,
@@ -39,7 +40,16 @@ export function AskAgentModal({
3940
}) {
4041
const [value, setValue] = useState("");
4142
const inputRef = useRef<HTMLTextAreaElement>(null);
43+
const containerRef = useRef<HTMLDivElement>(null);
4244
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+
});
4353

4454
useMountEffect(() => {
4555
requestAnimationFrame(() => inputRef.current?.focus());
@@ -54,13 +64,18 @@ export function AskAgentModal({
5464
<div
5565
className={
5666
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"
5969
}
60-
onClick={onClose}
70+
onClick={requestClose}
6171
>
6272
<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 ${
6479
anchorPoint ? "fixed" : ""
6580
}`}
6681
style={modalPositionStyle}
@@ -74,8 +89,9 @@ export function AskAgentModal({
7489
</p>
7590
</div>
7691
<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]"
7893
onClick={onClose}
94+
aria-label="Close"
7995
>
8096
<svg
8197
width="14"
@@ -100,7 +116,8 @@ export function AskAgentModal({
100116
onChange={(e) => setValue(e.target.value)}
101117
onKeyDown={(e) => {
102118
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.
104121
}}
105122
/>
106123
{contextPreview && (

packages/studio/src/components/LintModal.tsx

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useState } from "react";
1+
import { useRef, useState } from "react";
22
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
33
import { copyTextToClipboard } from "../utils/clipboard";
4+
import { useDialogBehavior } from "./ui/useDialogBehavior";
45

56
export interface LintFinding {
67
severity: "error" | "warning";
@@ -12,16 +13,28 @@ export interface LintFinding {
1213
export function LintModal({
1314
findings,
1415
projectId,
16+
projectDir,
17+
title = "HyperFrame Lint Results",
18+
promptIntro = "Fix these HyperFrames lint issues",
1519
onClose,
1620
}: {
1721
findings: LintFinding[];
1822
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;
1929
onClose: () => void;
2030
}) {
2131
const errors = findings.filter((f) => f.severity === "error");
2232
const warnings = findings.filter((f) => f.severity === "warning");
2333
const hasIssues = findings.length > 0;
2434
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 });
2538

2639
const handleCopyToAgent = async () => {
2740
const lines = findings.map((f) => {
@@ -30,21 +43,31 @@ export function LintModal({
3043
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
3144
return line;
3245
});
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")}`;
3448
const copiedText = await copyTextToClipboard(text);
3549
if (copiedText) {
3650
setCopied(true);
51+
setCopyFailed(false);
3752
setTimeout(() => setCopied(false), 2000);
53+
} else {
54+
setCopyFailed(true);
55+
setTimeout(() => setCopyFailed(false), 3000);
3856
}
3957
};
4058

4159
return (
4260
<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}
4563
>
4664
<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"
4871
onClick={(e) => e.stopPropagation()}
4972
>
5073
{/* Header */}
@@ -65,12 +88,13 @@ export function LintModal({
6588
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
6689
: "All checks passed"}
6790
</h2>
68-
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
91+
<p className="text-xs text-neutral-500">{title}</p>
6992
</div>
7093
</div>
7194
<button
7295
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]"
7498
>
7599
<XIcon size={16} />
76100
</button>
@@ -81,13 +105,19 @@ export function LintModal({
81105
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
82106
<button
83107
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] ${
85109
copied
86110
? "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"
88114
}`}
89115
>
90-
{copied ? "Copied!" : "Copy to Agent"}
116+
{copied
117+
? "Copied!"
118+
: copyFailed
119+
? "Copy failed — check permissions"
120+
: "Copy to Agent"}
91121
</button>
92122
</div>
93123
)}

packages/studio/src/components/MediaPreview.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,49 @@
1+
import { useState } from "react";
12
import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
23

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+
330
export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
431
const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
532
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} />;
639

740
if (IMAGE_EXT.test(filePath)) {
841
return (
942
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950">
1043
<img
1144
src={serveUrl}
1245
alt={name}
46+
onError={setFailed}
1347
className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
1448
/>
1549
<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
2357
<video
2458
src={serveUrl}
2559
controls
60+
onError={setFailed}
2661
className="max-w-full max-h-[70%] rounded border border-neutral-800"
2762
/>
2863
<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
4681
<circle cx="6" cy="18" r="3" />
4782
<circle cx="18" cy="16" r="3" />
4883
</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]" />
5085
<span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
5186
</div>
5287
);

packages/studio/src/components/SaveQueuePausedBanner.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
interface SaveQueuePausedBannerProps {
22
message: string;
3-
onDismiss: () => void;
3+
/** Resets the save-queue circuit breaker so persistence resumes. */
4+
onRetry: () => void;
45
}
56

67
/** 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) {
89
return (
910
<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"
1112
role="alert"
1213
>
1314
<span>{message}</span>
1415
<button
1516
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]"
1819
>
19-
Dismiss
20+
Retry saving
2021
</button>
2122
</div>
2223
);

0 commit comments

Comments
 (0)