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 { useRef, useState, useCallback, memo } from "react";
import { useRef, useState, useCallback, useEffect, memo } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { formatTime } from "../lib/time";
import { usePlayerStore, liveTime } from "../store/playerStore";
@@ -30,6 +30,8 @@ export const PlayerControls = memo(function PlayerControls({
const progressThumbRef = useRef<HTMLDivElement>(null);
const timeDisplayRef = useRef<HTMLSpanElement>(null);
const seekBarRef = useRef<HTMLDivElement>(null);
const sliderRef = useRef<HTMLDivElement>(null);
const speedMenuContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const currentTimeRef = useRef(0);
@@ -43,6 +45,7 @@ export const PlayerControls = memo(function PlayerControls({
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
};
const unsub = liveTime.subscribe(updateProgress);
updateProgress(usePlayerStore.getState().currentTime);
@@ -64,6 +67,22 @@ export const PlayerControls = memo(function PlayerControls({
};
});
useEffect(() => {
if (!showSpeedMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (
speedMenuContainerRef.current &&
!speedMenuContainerRef.current.contains(e.target as Node)
) {
setShowSpeedMenu(false);
}
};
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, [showSpeedMenu]);
const seekFromClientX = useCallback(
(clientX: number) => {
const bar = seekBarRef.current;
@@ -153,7 +172,10 @@ export const PlayerControls = memo(function PlayerControls({
{/* Seek bar — teal progress fill */}
<div
ref={seekBarRef}
ref={(el) => {
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
role="slider"
tabIndex={0}
aria-label="Seek"
@@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
</div>
{/* Speed control */}
<div className="relative flex-shrink-0">
<div ref={speedMenuContainerRef} className="relative flex-shrink-0">
<button
type="button"
onClick={() => setShowSpeedMenu((v) => !v)}
@@ -235,7 +257,7 @@ export const PlayerControls = memo(function PlayerControls({
onClick={onToggleTimeline}
className={`w-7 h-7 flex items-center justify-center rounded-md border transition-colors ${
timelineVisible
? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30"
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
: "border-neutral-700 text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800"
}`}
title={timelineVisible ? "Hide timeline" : "Show timeline"}