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,4 +1,4 @@
import { memo, useState, useCallback } from "react";
import { memo, useState, useCallback, useMemo } from "react";
import {
FileHtml,
FileCss,
@@ -7,10 +7,15 @@ import {
FileTs,
FileTsx,
FileTxt,
FileMd,
FileSvg,
FilePng,
FileJpg,
FileVideo,
FileCode,
File,
FilmStrip,
MusicNote,
Waveform,
TextAa,
Image as PhImage,
} from "@phosphor-icons/react";
import { ChevronDown, ChevronRight } from "../../icons/SystemIcons";
@@ -22,27 +27,36 @@ interface FileTreeProps {
}
const SZ = 14;
const W = "duotone" as const;
function FileIcon({ path }: { path: string }) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
const d = { size: SZ, weight: "duotone" as const, className: "flex-shrink-0" };
if (ext === "html") return <FileHtml {...d} color="#E44D26" />;
if (ext === "css") return <FileCss {...d} color="#264DE4" />;
if (ext === "js" || ext === "mjs" || ext === "cjs") return <FileJs {...d} color="#F0DB4F" />;
if (ext === "jsx") return <FileJsx {...d} color="#61DAFB" />;
if (ext === "ts" || ext === "mts") return <FileTs {...d} color="#3178C6" />;
if (ext === "tsx") return <FileTsx {...d} color="#3178C6" />;
if (ext === "txt" || ext === "md" || ext === "mdx") return <FileTxt {...d} color="#9CA3AF" />;
if (ext === "json" || ext === "svg") return <FileCode {...d} color="#22C55E" />;
if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "m4a")
return <MusicNote size={SZ} color="#3CE6AC" className="flex-shrink-0" />;
const c = "flex-shrink-0";
if (ext === "html") return <FileHtml size={SZ} weight={W} color="#E44D26" className={c} />;
if (ext === "css") return <FileCss size={SZ} weight={W} color="#264DE4" className={c} />;
if (ext === "js" || ext === "mjs" || ext === "cjs")
return <FileJs size={SZ} weight={W} color="#F0DB4F" className={c} />;
if (ext === "jsx") return <FileJsx size={SZ} weight={W} color="#61DAFB" className={c} />;
if (ext === "ts" || ext === "mts")
return <FileTs size={SZ} weight={W} color="#3178C6" className={c} />;
if (ext === "tsx") return <FileTsx size={SZ} weight={W} color="#3178C6" className={c} />;
if (ext === "json") return <FileCode size={SZ} weight={W} color="#4ADE80" className={c} />;
if (ext === "svg") return <FileSvg size={SZ} weight={W} color="#F97316" className={c} />;
if (ext === "md" || ext === "mdx")
return <FileMd size={SZ} weight={W} color="#9CA3AF" className={c} />;
if (ext === "txt") return <FileTxt size={SZ} weight={W} color="#9CA3AF" className={c} />;
if (ext === "png") return <FilePng size={SZ} weight={W} color="#22C55E" className={c} />;
if (ext === "jpg" || ext === "jpeg")
return <FileJpg size={SZ} weight={W} color="#22C55E" className={c} />;
if (ext === "webp" || ext === "gif" || ext === "ico")
return <PhImage size={SZ} weight={W} color="#22C55E" className={c} />;
if (ext === "mp4" || ext === "webm" || ext === "mov")
return <FilmStrip size={SZ} color="#A855F7" className="flex-shrink-0" />;
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp" || ext === "gif")
return <PhImage size={SZ} color="#22C55E" className="flex-shrink-0" />;
return <FileVideo size={SZ} weight={W} color="#A855F7" className={c} />;
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a")
return <Waveform size={SZ} weight={W} color="#3CE6AC" className={c} />;
if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf")
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
return <File size={SZ} weight="duotone" color="#6B7280" className="flex-shrink-0" />;
return <TextAa size={SZ} weight={W} color="#6B7280" className={c} />;
return <File size={SZ} weight={W} color="#6B7280" className={c} />;
}
interface TreeNode {
@@ -188,8 +202,8 @@ function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
}
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
const tree = buildTree(files);
const children = sortChildren(tree.children);
const tree = useMemo(() => buildTree(files), [files]);
const children = useMemo(() => sortChildren(tree.children), [tree]);
return (
<div className="flex flex-col h-full min-h-0">
@@ -94,7 +94,7 @@ export const PropertyPanel = memo(function PropertyPanel({
variant="secondary"
size="sm"
onClick={isPickMode ? onDisablePick : onEnablePick}
className={`mt-3 ${isPickMode ? "bg-blue-500/20 text-blue-400 border-blue-500/30" : ""}`}
className={`mt-3 ${isPickMode ? "bg-studio-accent/20 text-studio-accent border-studio-accent/30" : ""}`}
>
{isPickMode ? "Pick mode active..." : "Enable Pick Mode"}
</Button>
@@ -109,7 +109,7 @@ export const PropertyPanel = memo(function PropertyPanel({
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800 flex-shrink-0">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-2xs font-mono text-blue-400 truncate">{element.selector}</span>
<span className="text-2xs font-mono text-studio-accent truncate">{element.selector}</span>
</div>
<div className="flex items-center gap-1">
<IconButton
@@ -117,7 +117,7 @@ export const PropertyPanel = memo(function PropertyPanel({
aria-label={isPickMode ? "Disable pick mode" : "Enable pick mode"}
size="sm"
onClick={isPickMode ? onDisablePick : onEnablePick}
className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
className={isPickMode ? "text-studio-accent bg-studio-accent/10" : ""}
/>
<IconButton
icon={<X size={11} />}