refactor(studio): code quality — 22 findings, dead code removal, App.tsx split (#144)

## Summary

Full code quality review of the studio package, fixing 22 of 25 findings. Removes dead code, extracts modules from App.tsx, fixes accessibility and performance issues.

## Critical fixes (3)

- **`aria-valuenow`** on seek bar now updates imperatively via `liveTime.subscribe` — screen readers previously always reported position 0
- **Speed menu** closes on outside click (was permanently stuck open)
- **RenderQueue auto-scroll** moved from render phase to `useEffect` (was violating React render purity via `queueMicrotask` during render)

## Dead code removed (-331 lines)

| File | Lines | Why dead |
|---|---|---|
| `PreviewPanel.tsx` | 180 | Replaced by NLELayout + NLEPreview |
| `useCodeEditor.ts` | 80 | Exported but never imported |
| `formatTick` alias | 2 | Deprecated, unused |
| `onClipChange` prop | 5 | Declared, never used |
| `trackH` prop | 5 | Declared, never used |
| `editRange*` + updaters in store | 60 | Never read or written |

## App.tsx extraction

| Extracted to | Lines | What |
|---|---|---|
| `components/LintModal.tsx` | 130 | Lint results modal + LintFinding type |
| `components/MediaPreview.tsx` | 75 | Image/video/audio/font file previewer |
| `utils/mediaTypes.ts` | 15 | Shared regex constants (App.tsx and AssetsTab.tsx had diverged copies) |

## Performance fixes

- `useMemo` for `compositions`/`assets` derivation from `fileTree`
- `useMemo` for `buildTree(files)` in FileTree
- Debounced `handleContentChange` PUT (600ms — was firing on every keystroke)
- CompositionsTab iframe hover debounced (300ms — was mounting immediately)
- `VideoFrameThumbnail` re-extracts frame when `src` prop changes

## Not addressed (3 — low priority)

- #6: SystemIcons consolidation (large refactor across many files)
- #16-17: Overlay dismiss pattern standardization
- #18: Inline SVG → Phosphor replacement (gradual, per-PR)

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-31 04:21:12 +02:00
committed by GitHub
parent 1bc83c62a5
commit dac304ed9f
26 changed files with 475 additions and 753 deletions
@@ -1,9 +1,10 @@
import { memo, useState, useRef } from "react";
import { memo, useState, useRef, useEffect } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueProps {
jobs: RenderJob[];
projectId: string;
onDelete: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm") => void;
@@ -33,7 +34,7 @@ function FormatExportButton({
<button
onClick={() => onStartRender(format)}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
{isRendering ? "Rendering..." : "Export"}
</button>
@@ -43,21 +44,21 @@ function FormatExportButton({
export const RenderQueue = memo(function RenderQueue({
jobs,
projectId,
onDelete,
onClearCompleted,
onStartRender,
isRendering,
}: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(jobs.length);
// Auto-scroll to bottom when new jobs are added (adjust during render)
if (jobs.length > prevCount.current && listRef.current) {
queueMicrotask(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
});
}
prevCount.current = jobs.length;
// Auto-scroll to bottom when new jobs are added.
// Runs in an effect to avoid side effects during the render phase.
useEffect(() => {
if (listRef.current) {
listRef.current.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
}
}, [jobs.length]);
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
@@ -111,7 +112,12 @@ export const RenderQueue = memo(function RenderQueue({
</div>
) : (
jobs.map((job) => (
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
<RenderQueueItem
key={job.id}
job={job}
projectId={projectId}
onDelete={() => onDelete(job.id)}
/>
))
)}
</div>
@@ -4,6 +4,7 @@ import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
job: RenderJob;
projectId: string;
onDelete: () => void;
}
@@ -24,26 +25,30 @@ function formatTimeAgo(timestamp: number): string {
export const RenderQueueItem = memo(function RenderQueueItem({
job,
projectId,
onDelete,
}: RenderQueueItemProps) {
const [hovered, setHovered] = useState(false);
// Direct file URL — serves from disk, survives server restarts
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
const handleOpen = useCallback(() => {
window.open(`/api/render/${job.id}/view`, "_blank");
}, [job.id]);
window.open(fileSrc, "_blank");
}, [fileSrc]);
const handleDownload = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const a = document.createElement("a");
a.href = `/api/render/${job.id}/download`;
a.href = fileSrc;
a.download = job.filename;
a.click();
},
[job.id, job.filename],
[fileSrc, job.filename],
);
const viewSrc = `/api/render/${job.id}/view`;
const viewSrc = fileSrc;
const isComplete = job.status === "complete";
return (
@@ -85,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
{job.status === "rendering" && (
<div className="w-full h-full flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
</div>
)}
{job.status === "failed" && (
@@ -117,11 +122,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
<span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
className="h-full bg-studio-accent rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>