feat(studio): redesign left panel — Code tab, Renders header, Lint bottom (#141)

## Summary

Major layout redesign matching the Paper mockup. The Code editor moves to the left panel, Renders moves to the header, and interactions are simplified.

## Layout changes

| Before | After |
|---|---|
| Left: Compositions \| Assets | Left: **Code** \| Compositions \| Assets |
| Right: Code \| Renders tabs | Right: Renders-only (hidden by default) |
| Header: Projects ← / Lint / panel toggles | Header: title / sidebar toggle / **Renders button** |
| Lint: header button | Lint: **pinned to bottom** of left panel |

## Interaction changes

- **Renders panel** opens via a dedicated header button (teal when active), hidden by default
- **ExpandOnHover removed** — replaced with inline autoplay on thumbnails:
  - Compositions: hover shows a tiny iframe (1920px scaled to 80px, 300ms debounce)
  - Assets: hover shows `<video autoPlay muted loop>` in the thumbnail cell
- **Deleted** `ExpandOnHover.tsx` (194 lines) and `ExpandedVideoPreview.tsx` (35 lines)
- **Left panel max width**: 50% viewport, auto-expands to 50% when opening a file in Code tab

## Visual polish

- Equal-width tabs (Code \| Compositions \| Assets)
- Sidebar toggle button highlighted when panel is open
- Phosphor duotone file-type icons (`FileHtml`, `FileCss`, `FileJs`, `FileTs`, etc.)
- "FILES" header removed from file tree
- Redundant "RENDERS (N)" title removed from renders panel

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Miguel Ángel
2026-03-31 03:57:32 +02:00
committed by GitHub
parent 05a7b03841
commit 7b18c0352e
8 changed files with 263 additions and 704 deletions
@@ -1,194 +0,0 @@
import React, { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/react";
interface ExpandOnHoverProps {
children: ReactNode;
expandedContent?: ReactNode | ((close: () => void) => ReactNode);
expandScale?: number;
delay?: number;
className?: string;
onClick?: () => void;
}
export function ExpandOnHover({
children,
expandedContent,
expandScale = 0.75,
delay = 300,
className = "",
onClick,
}: ExpandOnHoverProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [origin, setOrigin] = useState({ x: 0, y: 0, w: 0, h: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const close = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsExpanded(false);
}, []);
const open = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setOrigin({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
setIsExpanded(true);
}, []);
const handleCardEnter = useCallback(() => {
if (isExpanded) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(open, delay);
}, [delay, open, isExpanded]);
const handleCardLeave = useCallback(() => {
if (isExpanded) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [isExpanded]);
// When expanded: track mouse position. If mouse stays outside the expanded
// card for 600ms continuously, close. Any re-entry resets the timer.
// Note: useEffect with [isExpanded] is acceptable — subscribes to window mousemove
// only while expanded, with cleanup on collapse. Can't be a mount effect.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isExpanded) return;
const CLOSE_DELAY = 600; // ms mouse must be outside to close
const START_DELAY = 400; // ms before we start checking (let animation settle)
let tracking = false;
const startTracking = setTimeout(() => {
tracking = true;
}, START_DELAY);
const handleMouseMove = (e: MouseEvent) => {
if (!tracking) return;
const el = expandedRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Add generous padding so edge movements don't trigger close
const pad = 20;
const inside =
e.clientX >= rect.left - pad &&
e.clientX <= rect.right + pad &&
e.clientY >= rect.top - pad &&
e.clientY <= rect.bottom + pad;
if (inside) {
// Mouse is inside — cancel any pending close
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
} else {
// Mouse is outside — start close countdown if not already started
if (!closeTimerRef.current) {
closeTimerRef.current = setTimeout(() => {
setIsExpanded(false);
}, CLOSE_DELAY);
}
}
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
clearTimeout(startTracking);
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
window.removeEventListener("mousemove", handleMouseMove);
};
}, [isExpanded]);
const vw = typeof window !== "undefined" ? window.innerWidth : 1440;
const vh = typeof window !== "undefined" ? window.innerHeight : 900;
const targetW = vw * expandScale;
const targetH = vh * expandScale;
const targetX = (vw - targetW) / 2;
const targetY = (vh - targetH) / 2;
return (
<>
<div
ref={containerRef}
className={className}
onMouseEnter={handleCardEnter}
onMouseLeave={handleCardLeave}
onClick={onClick}
style={{ opacity: isExpanded ? 0 : 1, transition: "opacity 100ms ease-out" }}
>
{children}
</div>
<AnimatePresence>
{isExpanded && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={close}
/>
{/* Expanded card */}
<motion.div
ref={expandedRef}
initial={{
left: origin.x,
top: origin.y,
width: origin.w,
height: origin.h,
}}
animate={{
left: targetX,
top: targetY,
width: targetW,
height: targetH,
}}
exit={{
left: origin.x,
top: origin.y,
width: origin.w,
height: origin.h,
}}
transition={{
type: "spring",
stiffness: 280,
damping: 28,
mass: 0.8,
}}
className="fixed z-50 overflow-hidden rounded-[16px] shadow-dialog"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
close();
onClick?.();
}}
>
{typeof expandedContent === "function"
? expandedContent(close)
: (expandedContent ?? children)}
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}
@@ -1,37 +0,0 @@
import type { ReactNode } from "react";
interface ExpandedVideoPreviewProps {
src: string;
name: string;
subtitle: string;
action: ReactNode;
}
/**
* Shared expanded video preview used by AssetsTab (video assets) and
* the Renders panel. Autoplays the video muted+looped inside a full-bleed
* card. Caller provides the footer action slot (Copy Path, Open, etc.).
*/
export function ExpandedVideoPreview({ src, name, subtitle, action }: ExpandedVideoPreviewProps) {
return (
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
<video
src={src}
autoPlay
muted
loop
playsInline
className="max-w-full max-h-full object-contain rounded"
/>
</div>
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
<div className="min-w-0 flex-1 mr-4">
<div className="text-sm font-medium text-neutral-200 truncate">{name}</div>
<div className="text-[10px] text-neutral-600 font-mono mt-0.5 truncate">{subtitle}</div>
</div>
{action}
</div>
</div>
);
}