From 594b1c1262b880578c6d35b749d01f537581b416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 01:05:38 +0200 Subject: [PATCH 1/8] =?UTF-8?q?refactor(studio):=20code=20quality=20?= =?UTF-8?q?=E2=80=94=20fix=2015=20findings=20from=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: - PlayerControls: aria-valuenow updates imperatively via liveTime sub - PlayerControls: speed menu closes on outside click - RenderQueue: auto-scroll moved from render phase to useEffect Dead code removed: - PreviewPanel.tsx (180 lines, replaced by NLELayout) - useCodeEditor.ts (unused hook) - formatTick deprecated alias from Timeline.tsx - onClipChange from TimelineProps (never used) - trackH prop from TimelineClip (never used) - editRangeStart/End/Mode + individual updaters from playerStore Fixes: - CompositionsTab iframe hover debounced (300ms) - VideoFrameThumbnail re-extracts when src changes - playerStore.reset() documents preserved fields --- .../src/components/renders/RenderQueue.tsx | 17 +- .../components/sidebar/CompositionsTab.tsx | 17 +- .../src/components/ui/VideoFrameThumbnail.tsx | 6 +- packages/studio/src/hooks/useCodeEditor.ts | 88 --------- packages/studio/src/index.ts | 3 - .../src/player/components/PlayerControls.tsx | 28 ++- .../src/player/components/PreviewPanel.tsx | 181 ------------------ .../studio/src/player/components/Timeline.tsx | 9 - .../src/player/components/TimelineClip.tsx | 1 - packages/studio/src/player/index.ts | 1 - .../studio/src/player/store/playerStore.ts | 31 +-- 11 files changed, 51 insertions(+), 331 deletions(-) delete mode 100644 packages/studio/src/hooks/useCodeEditor.ts delete mode 100644 packages/studio/src/player/components/PreviewPanel.tsx diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 9a6e23a5..4cf2bdd3 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useRef } from "react"; +import { memo, useState, useRef, useEffect } from "react"; import { RenderQueueItem } from "./RenderQueueItem"; import type { RenderJob } from "./useRenderQueue"; @@ -49,15 +49,14 @@ export const RenderQueue = memo(function RenderQueue({ isRendering, }: RenderQueueProps) { const listRef = useRef(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; diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 6a2d66bb..ecc276d0 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,4 +1,4 @@ -import { memo, useState } from "react"; +import { memo, useRef, useState } from "react"; interface CompositionsTabProps { projectId: string; @@ -19,6 +19,17 @@ function CompCard({ onSelect: () => void; }) { const [hovered, setHovered] = useState(false); + const hoverTimer = useRef | null>(null); + const handleEnter = () => { + hoverTimer.current = setTimeout(() => setHovered(true), 300); + }; + const handleLeave = () => { + if (hoverTimer.current) { + clearTimeout(hoverTimer.current); + hoverTimer.current = null; + } + setHovered(false); + }; const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`; const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`; @@ -26,8 +37,8 @@ function CompCard({ return (
setHovered(true)} - onPointerLeave={() => setHovered(false)} + onPointerEnter={handleEnter} + onPointerLeave={handleLeave} className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isActive ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]" diff --git a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx index ff45cf3b..f1c44e33 100644 --- a/packages/studio/src/components/ui/VideoFrameThumbnail.tsx +++ b/packages/studio/src/components/ui/VideoFrameThumbnail.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; /** * Extracts a representative JPEG frame from a video URL using a hidden @@ -7,12 +7,8 @@ import { useState, useEffect, useRef } from "react"; */ export function VideoFrameThumbnail({ src }: { src: string }) { const [frame, setFrame] = useState(null); - const didExtract = useRef(false); useEffect(() => { - if (didExtract.current) return; - didExtract.current = true; - const video = document.createElement("video"); video.crossOrigin = "anonymous"; video.muted = true; diff --git a/packages/studio/src/hooks/useCodeEditor.ts b/packages/studio/src/hooks/useCodeEditor.ts deleted file mode 100644 index 467343db..00000000 --- a/packages/studio/src/hooks/useCodeEditor.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { useState, useCallback } from "react"; - -export interface OpenFile { - path: string; - content: string; - savedContent: string; - isDirty: boolean; -} - -export interface UseCodeEditorReturn { - openFiles: OpenFile[]; - activeFilePath: string | null; - activeFile: OpenFile | null; - openFile: (path: string, content: string) => void; - closeFile: (path: string) => void; - setActiveFile: (path: string) => void; - updateContent: (content: string) => void; - markSaved: (path: string) => void; - /** External update — updates saved content, shows reload indicator */ - externalUpdate: (path: string, content: string) => void; -} - -export function useCodeEditor(): UseCodeEditorReturn { - const [openFiles, setOpenFiles] = useState([]); - const [activeFilePath, setActiveFilePath] = useState(null); - - const activeFile = openFiles.find((f) => f.path === activeFilePath) ?? null; - - const openFile = useCallback((path: string, content: string) => { - setOpenFiles((prev) => { - const existing = prev.find((f) => f.path === path); - if (existing) return prev; - return [...prev, { path, content, savedContent: content, isDirty: false }]; - }); - setActiveFilePath(path); - }, []); - - const closeFile = useCallback( - (path: string) => { - setOpenFiles((prev) => prev.filter((f) => f.path !== path)); - setActiveFilePath((prev) => { - if (prev === path) { - const remaining = openFiles.filter((f) => f.path !== path); - return remaining.length > 0 ? remaining[remaining.length - 1].path : null; - } - return prev; - }); - }, - [openFiles], - ); - - const updateContent = useCallback( - (content: string) => { - setOpenFiles((prev) => - prev.map((f) => - f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f, - ), - ); - }, - [activeFilePath], - ); - - const markSaved = useCallback((path: string) => { - setOpenFiles((prev) => - prev.map((f) => (f.path === path ? { ...f, savedContent: f.content, isDirty: false } : f)), - ); - }, []); - - const externalUpdate = useCallback((path: string, content: string) => { - setOpenFiles((prev) => - prev.map((f) => - f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f, - ), - ); - }, []); - - return { - openFiles, - activeFilePath, - activeFile, - openFile, - closeFile, - setActiveFile: setActiveFilePath, - updateContent, - markSaved, - externalUpdate, - }; -} diff --git a/packages/studio/src/index.ts b/packages/studio/src/index.ts index 9c7b9be4..d788f6bb 100644 --- a/packages/studio/src/index.ts +++ b/packages/studio/src/index.ts @@ -9,7 +9,6 @@ export { Player, PlayerControls, Timeline, - PreviewPanel, VideoThumbnail, CompositionThumbnail, useTimelinePlayer, @@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree"; export { StudioApp } from "./App"; // Hooks -export { useCodeEditor } from "./hooks/useCodeEditor"; -export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor"; export { useElementPicker } from "./hooks/useElementPicker"; export type { PickedElement } from "./hooks/useElementPicker"; diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index 86a8a971..cd53f426 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -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(null); const timeDisplayRef = useRef(null); const seekBarRef = useRef(null); + const sliderRef = useRef(null); + const speedMenuContainerRef = useRef(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 */}
{ + (seekBarRef as React.MutableRefObject).current = el; + (sliderRef as React.MutableRefObject).current = el; + }} role="slider" tabIndex={0} aria-label="Seek" @@ -188,7 +210,7 @@ export const PlayerControls = memo(function PlayerControls({
{/* Speed control */} -
+
- )} -
- )} -
- )} - - {/* Optional custom slot */} - {children} - - ) : ( -
-
-
- - - -
-

Preview will appear here

-

- Send a message to generate a video composition -

-
-
- )} -
- ); -} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 0ac424ce..2191e1a0 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -152,9 +152,6 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe return { major, minor }; } -/** @deprecated Use formatTime from '../lib/time' instead */ -export const formatTick = formatTime; - /* ── Component ──────────────────────────────────────────────────── */ interface TimelineProps { /** Called when user seeks via ruler/track click or playhead drag */ @@ -170,11 +167,6 @@ interface TimelineProps { renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode; /** Called when files are dropped onto the empty timeline */ onFileDrop?: (files: File[]) => void; - /** Called when a clip is moved, resized, or changes track via drag */ - onClipChange?: ( - elementId: string, - updates: { start?: number; duration?: number; track?: number }, - ) => void; } export const Timeline = memo(function Timeline({ @@ -642,7 +634,6 @@ export const Timeline = memo(function Timeline({ key={clipKey} el={el} pps={pps} - trackH={TRACK_H} clipY={CLIP_Y} isSelected={isSelected} isHovered={isHovered} diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index b33c52e1..ba8deb74 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -6,7 +6,6 @@ import type { TimelineElement } from "../store/playerStore"; interface TimelineClipProps { el: TimelineElement; pps: number; - trackH: number; clipY: number; isSelected: boolean; isHovered: boolean; diff --git a/packages/studio/src/player/index.ts b/packages/studio/src/player/index.ts index 1c09cc5e..cea14c0c 100644 --- a/packages/studio/src/player/index.ts +++ b/packages/studio/src/player/index.ts @@ -2,7 +2,6 @@ export { Player } from "./components/Player"; export { PlayerControls } from "./components/PlayerControls"; export { Timeline } from "./components/Timeline"; -export { PreviewPanel } from "./components/PreviewPanel"; export { VideoThumbnail } from "./components/VideoThumbnail"; export { CompositionThumbnail } from "./components/CompositionThumbnail"; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 89eb44df..8b5be919 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -27,10 +27,6 @@ interface PlayerState { zoomMode: ZoomMode; /** Pixels per second when in manual zoom mode */ pixelsPerSecond: number; - /** Edit range selection */ - editRangeStart: number | null; - editRangeEnd: number | null; - editMode: boolean; setIsPlaying: (playing: boolean) => void; setCurrentTime: (time: number) => void; @@ -39,11 +35,6 @@ interface PlayerState { setTimelineReady: (ready: boolean) => void; setElements: (elements: TimelineElement[]) => void; setSelectedElementId: (id: string | null) => void; - setEditRange: (start: number | null, end: number | null) => void; - setEditMode: (active: boolean) => void; - updateElementStart: (elementId: string, newStart: number) => void; - updateElementDuration: (elementId: string, newDuration: number) => void; - updateElementTrack: (elementId: string, newTrack: number) => void; updateElement: ( elementId: string, updates: Partial>, @@ -76,9 +67,6 @@ export const usePlayerStore = create((set) => ({ playbackRate: 1, zoomMode: "fit", pixelsPerSecond: 100, - editRangeStart: null, - editRangeEnd: null, - editMode: false, setIsPlaying: (playing) => set({ isPlaying: playing }), setPlaybackRate: (rate) => set({ playbackRate: rate }), @@ -89,26 +77,13 @@ export const usePlayerStore = create((set) => ({ setTimelineReady: (ready) => set({ timelineReady: ready }), setElements: (elements) => set({ elements }), setSelectedElementId: (id) => set({ selectedElementId: id }), - setEditRange: (start, end) => set({ editRangeStart: start, editRangeEnd: end }), - setEditMode: (active) => set({ editMode: active, editRangeStart: null, editRangeEnd: null }), - updateElementStart: (elementId, newStart) => - set((state) => ({ - elements: state.elements.map((el) => (el.id === elementId ? { ...el, start: newStart } : el)), - })), - updateElementDuration: (elementId, newDuration) => - set((state) => ({ - elements: state.elements.map((el) => - el.id === elementId ? { ...el, duration: newDuration } : el, - ), - })), - updateElementTrack: (elementId, newTrack) => - set((state) => ({ - elements: state.elements.map((el) => (el.id === elementId ? { ...el, track: newTrack } : el)), - })), updateElement: (elementId, updates) => set((state) => ({ elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)), })), + // Resets project-specific state when switching compositions. + // playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved + // because they are user preferences that should survive project switches. reset: () => set({ isPlaying: false, From 9662d324680dc8b34c3dd73211c7a0438353b208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 01:09:18 +0200 Subject: [PATCH 2/8] refactor(studio): extract LintModal, MediaPreview, shared media regexes from App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract LintModal (130 lines) to components/LintModal.tsx - Extract MediaPreview (75 lines) to components/MediaPreview.tsx - Extract shared media regexes to utils/mediaTypes.ts (single source of truth — App.tsx and AssetsTab.tsx were silently diverged) - Add useMemo for compositions/assets derivation and FileTree buildTree - Debounce handleContentChange PUT (600ms, was firing every keystroke) - Guard projectId null in LintModal render --- packages/studio/src/App.tsx | 287 +++--------------- packages/studio/src/components/LintModal.tsx | 141 +++++++++ .../studio/src/components/MediaPreview.tsx | 79 +++++ .../studio/src/components/editor/FileTree.tsx | 6 +- .../src/components/sidebar/AssetsTab.tsx | 6 +- packages/studio/src/utils/mediaTypes.ts | 9 + 6 files changed, 267 insertions(+), 261 deletions(-) create mode 100644 packages/studio/src/components/LintModal.tsx create mode 100644 packages/studio/src/components/MediaPreview.tsx create mode 100644 packages/studio/src/utils/mediaTypes.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index f8ffb569..e15f02a8 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react"; import { useMountEffect } from "./hooks/useMountEffect"; import { NLELayout } from "./components/nle/NLELayout"; import { SourceEditor } from "./components/editor/SourceEditor"; @@ -8,245 +8,16 @@ import { useRenderQueue } from "./components/renders/useRenderQueue"; import { CompositionThumbnail, VideoThumbnail } from "./player"; import { AudioWaveform } from "./player/components/AudioWaveform"; import type { TimelineElement } from "./player"; -import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react"; +import { LintModal } from "./components/LintModal"; +import type { LintFinding } from "./components/LintModal"; +import { MediaPreview } from "./components/MediaPreview"; +import { isMediaFile } from "./utils/mediaTypes"; interface EditingFile { path: string; content: string | null; } -interface LintFinding { - severity: "error" | "warning"; - message: string; - file?: string; - fixHint?: string; -} - -// ── Media file detection and preview ── - -const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i; -const VIDEO_EXT = /\.(mp4|webm|mov)$/i; -const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i; -const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i; - -function isMediaFile(path: string): boolean { - return ( - IMAGE_EXT.test(path) || VIDEO_EXT.test(path) || AUDIO_EXT.test(path) || FONT_EXT.test(path) - ); -} - -function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { - const serveUrl = `/api/projects/${projectId}/preview/${filePath}`; - const name = filePath.split("/").pop() ?? filePath; - - if (IMAGE_EXT.test(filePath)) { - return ( -
- {name} - {filePath} -
- ); - } - - if (VIDEO_EXT.test(filePath)) { - return ( -
-
- ); - } - - if (AUDIO_EXT.test(filePath)) { - return ( -
- - - - - -
- ); - } - - // Fonts and other binary — show info instead of binary dump - return ( -
- - - - - {name} - {filePath} - Binary file — preview not available -
- ); -} - -// ── Lint Modal ── - -function LintModal({ - findings, - projectId, - onClose, -}: { - findings: LintFinding[]; - projectId: string; - onClose: () => void; -}) { - const errors = findings.filter((f) => f.severity === "error"); - const warnings = findings.filter((f) => f.severity === "warning"); - const hasIssues = findings.length > 0; - const [copied, setCopied] = useState(false); - - const handleCopyToAgent = async () => { - const lines = findings.map((f) => { - let line = `[${f.severity}] ${f.message}`; - if (f.file) line += `\n File: ${f.file}`; - if (f.fixHint) line += `\n Fix: ${f.fixHint}`; - return line; - }); - const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`; - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // ignore - } - }; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
- {hasIssues ? ( -
- -
- ) : ( -
- -
- )} -
-

- {hasIssues - ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}` - : "All checks passed"} -

-

HyperFrame Lint Results

-
-
- -
- - {/* Copy to agent + findings */} - {hasIssues && ( -
- -
- )} -
- {!hasIssues && ( -
- No errors or warnings found. Your composition looks good! -
- )} - {errors.map((f, i) => ( -
-
- -
-

{f.message}

- {f.file &&

{f.file}

} - {f.fixHint && ( -
- -

{f.fixHint}

-
- )} -
-
-
- ))} - {warnings.map((f, i) => ( -
-
- -
-

{f.message}

- {f.file &&

{f.file}

} - {f.fixHint && ( -
- -

{f.fixHint}

-
- )} -
-
-
- ))} -
-
-
- ); -} - // ── Main App ── export function StudioApp() { @@ -389,6 +160,7 @@ export function StudioApp() { const [linting, setLinting] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const refreshTimerRef = useRef | null>(null); + const saveTimerRef = useRef | null>(null); const projectIdRef = useRef(projectId); const previewIframeRef = useRef(null); @@ -454,20 +226,24 @@ export function StudioApp() { const handleContentChange = useCallback((content: string) => { const pid = projectIdRef.current; + if (!pid) return; const path = editingPathRef.current; - if (!pid || !path) return; - // Don't update editingFile state — the editor manages its own content. - // Only save to disk and refresh the preview. - fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, { - method: "PUT", - headers: { "Content-Type": "text/plain" }, - body: content, - }) - .then(() => { - if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); - refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600); + if (!path) return; + + // Debounce the server write (600ms) + if (saveTimerRef.current) clearTimeout(saveTimerRef.current); + saveTimerRef.current = setTimeout(() => { + fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`, { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: content, }) - .catch(() => {}); + .then(() => { + if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = setTimeout(() => setRefreshKey((k) => k + 1), 600); + }) + .catch(() => {}); + }, 600); }, []); const handleLint = useCallback(async () => { @@ -528,6 +304,16 @@ export function StudioApp() { panelDragRef.current = null; }, []); + const compositions = useMemo( + () => fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")), + [fileTree], + ); + const assets = useMemo( + () => + fileTree.filter((f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json")), + [fileTree], + ); + if (resolving || !projectId) { return (
@@ -538,11 +324,6 @@ export function StudioApp() { // At this point projectId is guaranteed non-null (narrowed by the guard above) - const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/")); - const assets = fileTree.filter( - (f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"), - ); - return (
{/* Header bar */} @@ -655,7 +436,7 @@ export function StudioApp() { codeChildren={ editingFile ? ( isMediaFile(editingFile.path) ? ( - + ) : ( {/* Lint modal */} - {lintModal !== null && ( + {lintModal !== null && projectId && ( setLintModal(null)} /> )}
diff --git a/packages/studio/src/components/LintModal.tsx b/packages/studio/src/components/LintModal.tsx new file mode 100644 index 00000000..20722612 --- /dev/null +++ b/packages/studio/src/components/LintModal.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react"; + +export interface LintFinding { + severity: "error" | "warning"; + message: string; + file?: string; + fixHint?: string; +} + +export function LintModal({ + findings, + projectId, + onClose, +}: { + findings: LintFinding[]; + projectId: string; + onClose: () => void; +}) { + const errors = findings.filter((f) => f.severity === "error"); + const warnings = findings.filter((f) => f.severity === "warning"); + const hasIssues = findings.length > 0; + const [copied, setCopied] = useState(false); + + const handleCopyToAgent = async () => { + const lines = findings.map((f) => { + let line = `[${f.severity}] ${f.message}`; + if (f.file) line += `\n File: ${f.file}`; + if (f.fixHint) line += `\n Fix: ${f.fixHint}`; + return line; + }); + const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // ignore + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {hasIssues ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {hasIssues + ? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}` + : "All checks passed"} +

+

HyperFrame Lint Results

+
+
+ +
+ + {/* Copy to agent + findings */} + {hasIssues && ( +
+ +
+ )} +
+ {!hasIssues && ( +
+ No errors or warnings found. Your composition looks good! +
+ )} + {errors.map((f, i) => ( +
+
+ +
+

{f.message}

+ {f.file &&

{f.file}

} + {f.fixHint && ( +
+ +

{f.fixHint}

+
+ )} +
+
+
+ ))} + {warnings.map((f, i) => ( +
+
+ +
+

{f.message}

+ {f.file &&

{f.file}

} + {f.fixHint && ( +
+ +

{f.fixHint}

+
+ )} +
+
+
+ ))} +
+
+
+ ); +} diff --git a/packages/studio/src/components/MediaPreview.tsx b/packages/studio/src/components/MediaPreview.tsx new file mode 100644 index 00000000..1eb392e4 --- /dev/null +++ b/packages/studio/src/components/MediaPreview.tsx @@ -0,0 +1,79 @@ +import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes"; + +export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { + const serveUrl = `/api/projects/${projectId}/preview/${filePath}`; + const name = filePath.split("/").pop() ?? filePath; + + if (IMAGE_EXT.test(filePath)) { + return ( +
+ {name} + {filePath} +
+ ); + } + + if (VIDEO_EXT.test(filePath)) { + return ( +
+
+ ); + } + + if (AUDIO_EXT.test(filePath)) { + return ( +
+ + + + + +
+ ); + } + + // Fonts and other binary — show info instead of binary dump + return ( +
+ + + + + {name} + {filePath} + Binary file — preview not available +
+ ); +} diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 18a43cfa..02a7d0af 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback } from "react"; +import { memo, useState, useCallback, useMemo } from "react"; import { FileHtml, FileCss, @@ -188,8 +188,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 (
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 9c216930..dd5bb8e1 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,5 +1,6 @@ import { memo, useState, useCallback, useRef } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; +import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes"; interface AssetsTabProps { projectId: string; @@ -7,11 +8,6 @@ interface AssetsTabProps { onImport?: (files: FileList) => void; } -const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i; -const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i; -const VIDEO_EXT = /\.(mp4|webm|mov)$/i; -const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i; - /** Inline thumbnail content — rendered inside the container div in AssetCard. */ function AssetThumbnail({ serveUrl, diff --git a/packages/studio/src/utils/mediaTypes.ts b/packages/studio/src/utils/mediaTypes.ts new file mode 100644 index 00000000..ebc41540 --- /dev/null +++ b/packages/studio/src/utils/mediaTypes.ts @@ -0,0 +1,9 @@ +export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i; +export const VIDEO_EXT = /\.(mp4|webm|mov)$/i; +export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i; +export const FONT_EXT = /\.(woff|woff2|ttf|otf|eot)$/i; +export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i; + +export function isMediaFile(path: string): boolean { + return MEDIA_EXT.test(path); +} From 559ac817491156c1f0dee6ecefebbb6cecb59b5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 01:34:38 +0200 Subject: [PATCH 3/8] feat(studio): use specific phosphor icons for each file type in tree --- .../studio/src/components/editor/FileTree.tsx | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/components/editor/FileTree.tsx b/packages/studio/src/components/editor/FileTree.tsx index 02a7d0af..9bc2c40d 100644 --- a/packages/studio/src/components/editor/FileTree.tsx +++ b/packages/studio/src/components/editor/FileTree.tsx @@ -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 ; - if (ext === "css") return ; - if (ext === "js" || ext === "mjs" || ext === "cjs") return ; - if (ext === "jsx") return ; - if (ext === "ts" || ext === "mts") return ; - if (ext === "tsx") return ; - if (ext === "txt" || ext === "md" || ext === "mdx") return ; - if (ext === "json" || ext === "svg") return ; - if (ext === "wav" || ext === "mp3" || ext === "ogg" || ext === "m4a") - return ; + const c = "flex-shrink-0"; + if (ext === "html") return ; + if (ext === "css") return ; + if (ext === "js" || ext === "mjs" || ext === "cjs") + return ; + if (ext === "jsx") return ; + if (ext === "ts" || ext === "mts") + return ; + if (ext === "tsx") return ; + if (ext === "json") return ; + if (ext === "svg") return ; + if (ext === "md" || ext === "mdx") + return ; + if (ext === "txt") return ; + if (ext === "png") return ; + if (ext === "jpg" || ext === "jpeg") + return ; + if (ext === "webp" || ext === "gif" || ext === "ico") + return ; if (ext === "mp4" || ext === "webm" || ext === "mov") - return ; - if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "webp" || ext === "gif") - return ; + return ; + if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "m4a") + return ; if (ext === "woff" || ext === "woff2" || ext === "ttf" || ext === "otf") - return ; - return ; + return ; + return ; } interface TreeNode { From e2a3baddccf8db2ca2288cfbd0d9d6062fec5d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 02:39:41 +0200 Subject: [PATCH 4/8] docs: warn about global install shadowing pnpm link in testing guide --- docs/contributing/testing-local-changes.mdx | 22 ++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/contributing/testing-local-changes.mdx b/docs/contributing/testing-local-changes.mdx index f8cfec38..c97f3ed8 100644 --- a/docs/contributing/testing-local-changes.mdx +++ b/docs/contributing/testing-local-changes.mdx @@ -19,12 +19,18 @@ pnpm build `pnpm link --global` makes the `hyperframes` binary in your `$PATH` point at your local build. It survives across terminal sessions and auto-picks up new builds without re-linking. ```bash -# One-time setup +# If you previously installed hyperframes globally, remove it first — +# a global install takes priority over pnpm link and shadows your local build. +pnpm remove -g hyperframes 2>/dev/null || npm uninstall -g hyperframes 2>/dev/null + +# Link your local build cd packages/cli pnpm link --global -# Verify — should print your local version +# Verify — should print your local version AND point to the monorepo hyperframes --version +which hyperframes +# The path should contain your monorepo, NOT pnpm/global/.pnpm/hyperframes@... ``` Now use `hyperframes` normally in any directory: @@ -103,19 +109,21 @@ Common test scenarios: The CLI binary is a single bundled file at `packages/cli/dist/cli.js`. If your change is in `@hyperframes/core` or another workspace package, make sure `pnpm build` rebuilt _all_ packages — the CLI bundles its dependencies at build time. -**`hyperframes` still shows the old version** +**`hyperframes` still shows the old version / old UI** -Check which binary is active: +A globally installed `hyperframes` package shadows `pnpm link`. Check which binary is active: ```bash which hyperframes -hyperframes --version +# BAD: /Users/you/Library/pnpm/hyperframes → pnpm/global/.pnpm/hyperframes@0.x.x/... +# GOOD: /Users/you/Library/pnpm/hyperframes → your-monorepo/packages/cli/dist/cli.js ``` -If it points to a global npm installation rather than your link, uninstall the npm version first: +If it points to the global store, remove the global install and re-link: ```bash -npm uninstall -g hyperframes +pnpm remove -g hyperframes +npm uninstall -g hyperframes # in case it was installed via npm cd packages/cli && pnpm link --global ``` From eab61a2780d07b9b0e73dae4789756c0cb14d2a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 03:04:29 +0200 Subject: [PATCH 5/8] fix(studio): serve render files from disk instead of in-memory map The /api/render/:jobId/view route depended on an in-memory renderJobs map that's empty after server restart, causing 404s. Replace with a /api/projects/:id/renders/file/:filename route that reads directly from the renders directory on disk. Both view and download now use this path. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/core/src/studio-api/routes/render.ts | 22 +++++++++++++++++++ packages/studio/src/App.tsx | 1 + .../src/components/renders/RenderQueue.tsx | 9 +++++++- .../components/renders/RenderQueueItem.tsx | 15 ++++++++----- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/core/src/studio-api/routes/render.ts b/packages/core/src/studio-api/routes/render.ts index a9662abc..67731b5e 100644 --- a/packages/core/src/studio-api/routes/render.ts +++ b/packages/core/src/studio-api/routes/render.ts @@ -183,6 +183,28 @@ export function registerRenderRoutes(api: Hono, adapter: StudioApiAdapter): void return c.json({ deleted: true }); }); + // Serve render file directly from disk (no in-memory map dependency) + api.get("/projects/:id/renders/file/*", async (c) => { + const project = await adapter.resolveProject(c.req.param("id")); + if (!project) return c.json({ error: "not found" }, 404); + const filename = c.req.path.split("/renders/file/")[1]; + if (!filename) return c.json({ error: "missing filename" }, 400); + const rendersDir = adapter.rendersDir(project); + const fp = join(rendersDir, filename); + if (!existsSync(fp)) return c.json({ error: "not found" }, 404); + const isWebm = fp.endsWith(".webm"); + const contentType = isWebm ? "video/webm" : "video/mp4"; + const content = readFileSync(fp); + return new Response(content, { + headers: { + "Content-Type": contentType, + "Content-Disposition": `inline; filename="${filename}"`, + "Accept-Ranges": "bytes", + "Content-Length": String(content.length), + }, + }); + }); + // List renders api.get("/projects/:id/renders", async (c) => { const project = await adapter.resolveProject(c.req.param("id")); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index e15f02a8..04ce7cb9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -499,6 +499,7 @@ export function StudioApp() { > renderQueue.startRender(30, "standard", format)} diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 4cf2bdd3..1c3ea8e9 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -4,6 +4,7 @@ import type { RenderJob } from "./useRenderQueue"; interface RenderQueueProps { jobs: RenderJob[]; + projectId: string; onDelete: (jobId: string) => void; onClearCompleted: () => void; onStartRender: (format: "mp4" | "webm") => void; @@ -43,6 +44,7 @@ function FormatExportButton({ export const RenderQueue = memo(function RenderQueue({ jobs, + projectId, onDelete, onClearCompleted, onStartRender, @@ -110,7 +112,12 @@ export const RenderQueue = memo(function RenderQueue({
) : ( jobs.map((job) => ( - onDelete(job.id)} /> + onDelete(job.id)} + /> )) )}
diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index b3b9e416..758f518a 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -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 ( From c7694c523a1f0032e3e755eaeb05926a95ea4a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 03:13:43 +0200 Subject: [PATCH 6/8] fix(studio): use consistent green accent for left sidebar toggle Match the same #3CE6AC active style used by timeline and renders toggles. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/studio/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 04ce7cb9..fc796fbe 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -338,7 +338,7 @@ export function StudioApp() { onClick={() => setLeftCollapsed((v) => !v)} className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ !leftCollapsed - ? "bg-neutral-800 border-neutral-700 text-neutral-300" + ? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30" : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800" }`} title={leftCollapsed ? "Show sidebar" : "Hide sidebar"} From 821da4fc9abddde03388e5c90889909fd50c4b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 03:18:35 +0200 Subject: [PATCH 7/8] feat(studio): single-frame composition thumbnail + shift+drag on clips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CompositionThumbnail now takes one screenshot at the midpoint and stretches it across the clip (object-cover), same approach as After Effects for precomps. Avoids 6x Puppeteer render cost per composition. Timeline shift+drag range selection now works even when starting on a clip — the [data-clip] early return was blocking the shiftKey check. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/CompositionThumbnail.tsx | 114 +++--------------- .../studio/src/player/components/Timeline.tsx | 9 +- 2 files changed, 26 insertions(+), 97 deletions(-) diff --git a/packages/studio/src/player/components/CompositionThumbnail.tsx b/packages/studio/src/player/components/CompositionThumbnail.tsx index 66709df2..e558b1b9 100644 --- a/packages/studio/src/player/components/CompositionThumbnail.tsx +++ b/packages/studio/src/player/components/CompositionThumbnail.tsx @@ -1,18 +1,12 @@ /** - * CompositionThumbnail — Film-strip of server-rendered JPEG thumbnails. + * CompositionThumbnail — Single server-rendered JPEG stretched across the clip. * - * Requests multiple thumbnails at different timestamps across the clip duration - * and tiles them horizontally — like VideoThumbnail does for video clips. - * Each frame is a separate from /api/projects/:id/thumbnail/:path?t=X. - * - * Uses ResizeObserver to adapt frame count when the clip width changes (zoom). + * Takes one screenshot at the midpoint of the clip and covers the full width — + * same approach as After Effects for precomps. This avoids the 1-2s per-frame + * Puppeteer cost of rendering multiple filmstrip frames. */ -import { memo, useRef, useState, useCallback } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; - -const CLIP_HEIGHT = 66; -const MAX_UNIQUE_FRAMES = 6; +import { memo } from "react"; interface CompositionThumbnailProps { previewUrl: string; @@ -30,95 +24,27 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({ labelColor, seekTime = 2, duration = 5, - width = 1920, - height = 1080, }: CompositionThumbnailProps) { - const [containerWidth, setContainerWidth] = useState(0); - const roRef = useRef(null); - - const setRef = useCallback((el: HTMLDivElement | null) => { - roRef.current?.disconnect(); - if (!el) return; - - // Walk up to data-clip parent for accurate width - let target: HTMLElement = el; - let parent = el.parentElement; - let depth = 0; - while (parent && !parent.hasAttribute("data-clip") && depth < 5) { - parent = parent.parentElement; - depth++; - } - if (parent?.hasAttribute("data-clip")) target = parent; - - requestAnimationFrame(() => { - const w = target.clientWidth || target.getBoundingClientRect().width; - if (w > 0) setContainerWidth(w); - }); - - roRef.current = new ResizeObserver(([entry]) => setContainerWidth(entry.contentRect.width)); - roRef.current.observe(target); - }, []); - - useMountEffect(() => () => { - roRef.current?.disconnect(); - }); - - // Convert preview URL to thumbnail base URL + // Single screenshot at the midpoint of the clip const thumbnailBase = previewUrl .replace("/preview/comp/", "/thumbnail/") .replace(/\/preview$/, "/thumbnail/index.html"); - - // Calculate frame layout - const aspect = width / height; - const frameW = Math.round(CLIP_HEIGHT * aspect); - const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1; - const uniqueFrames = Math.min(frameCount, MAX_UNIQUE_FRAMES); - - // Each frame tile represents a real position in the clip. - // Offset slightly (0.5s) into each segment to avoid landing on transition - // points where content is invisible due to fade-in/fade-out animations. - const timestamps: number[] = []; - const pad = Math.min(0.5, duration * 0.05); - for (let i = 0; i < uniqueFrames; i++) { - const frac = uniqueFrames === 1 ? 0.5 : i / (uniqueFrames - 1); - const raw = seekTime + frac * duration; - // Clamp to [pad, duration - pad] to stay inside visible content - timestamps.push(seekTime + Math.max(pad, Math.min(duration - pad, raw - seekTime))); - } + const midTime = seekTime + duration / 2; + const url = `${thumbnailBase}?t=${midTime.toFixed(2)}`; return ( -
- {/* Film strip — each tile maps to its real timeline position */} -
- {Array.from({ length: frameCount }).map((_, i) => { - // Map this tile's visual position to a timestamp - const tileFrac = frameCount === 1 ? 0.5 : i / (frameCount - 1); - const t = seekTime + tileFrac * duration; - // Use the nearest cached unique frame - const uniqueIdx = Math.min(Math.round(tileFrac * (uniqueFrames - 1)), uniqueFrames - 1); - const cachedT = timestamps[uniqueIdx]; - const url = `${thumbnailBase}?t=${(cachedT ?? t).toFixed(2)}`; - return ( -
- { - (e.target as HTMLImageElement).style.opacity = "1"; - }} - className="absolute inset-0 w-full h-full object-contain" - style={{ opacity: 0, transition: "opacity 200ms ease-out" }} - /> -
- ); - })} -
+
+ { + (e.target as HTMLImageElement).style.opacity = "1"; + }} + className="absolute inset-0 w-full h-full object-cover" + style={{ opacity: 0, transition: "opacity 200ms ease-out" }} + /> {/* Label */}
{ - if ((e.target as HTMLElement).closest("[data-clip]")) return; if (e.button !== 0) return; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - // Shift+click starts range selection + // Shift+click starts range selection — even on clips if (e.shiftKey) { + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); isRangeSelecting.current = true; setShowPopover(false); const rect = scrollRef.current?.getBoundingClientRect(); @@ -356,6 +355,10 @@ export const Timeline = memo(function Timeline({ return; } + // Normal click on a clip — let the clip handle it + if ((e.target as HTMLElement).closest("[data-clip]")) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + isDragging.current = true; setRangeSelection(null); setShowPopover(false); From 5e8927f6475cb6feb43dc17cd1a82b113e218b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 31 Mar 2026 03:26:28 +0200 Subject: [PATCH 8/8] refactor(studio): unify accent color under studio-accent Tailwind token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all 30 hardcoded [#3CE6AC] and blue-400/blue-500 Tailwind classes with the studio-accent token defined in tailwind.config.js. Single source of truth — change the accent in one place. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/studio/src/App.tsx | 12 +++++----- packages/studio/src/components/LintModal.tsx | 22 +++++++++++++------ .../src/components/editor/PropertyPanel.tsx | 6 ++--- .../studio/src/components/nle/NLELayout.tsx | 2 +- .../src/components/renders/RenderQueue.tsx | 2 +- .../components/renders/RenderQueueItem.tsx | 6 ++--- .../src/components/sidebar/AssetsTab.tsx | 6 ++--- .../components/sidebar/CompositionsTab.tsx | 2 +- .../src/components/sidebar/LeftSidebar.tsx | 6 ++--- .../src/player/components/EditModal.tsx | 10 ++++----- .../src/player/components/PlayerControls.tsx | 2 +- .../studio/src/player/components/Timeline.tsx | 12 +++++----- packages/studio/tailwind.config.js | 2 +- 13 files changed, 50 insertions(+), 40 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index fc796fbe..4728395f 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -317,7 +317,7 @@ export function StudioApp() { if (resolving || !projectId) { return (
-
+
); } @@ -338,7 +338,7 @@ export function StudioApp() { onClick={() => setLeftCollapsed((v) => !v)} className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${ !leftCollapsed - ? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30" + ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30" : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800" }`} title={leftCollapsed ? "Show sidebar" : "Hide sidebar"} @@ -361,7 +361,7 @@ export function StudioApp() { onClick={() => setTimelineVisible((v) => !v)} className={`h-7 w-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" : "bg-transparent border-transparent text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800" }`} title={timelineVisible ? "Hide timeline" : "Show timeline"} @@ -384,7 +384,7 @@ export function StudioApp() { onClick={() => setRightCollapsed((v) => !v)} className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${ !rightCollapsed - ? "text-[#3CE6AC] bg-[#3CE6AC]/10 border-[#3CE6AC]/30" + ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30" : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent" }`} > @@ -454,7 +454,7 @@ export function StudioApp() { {/* Left resize handle */} {!leftCollapsed && (
handlePanelResizeStart("left", e)} onPointerMove={handlePanelResizeMove} @@ -487,7 +487,7 @@ export function StudioApp() { {!rightCollapsed && ( <>
handlePanelResizeStart("right", e)} onPointerMove={handlePanelResizeMove} diff --git a/packages/studio/src/components/LintModal.tsx b/packages/studio/src/components/LintModal.tsx index 20722612..c71d5ed1 100644 --- a/packages/studio/src/components/LintModal.tsx +++ b/packages/studio/src/components/LintModal.tsx @@ -56,8 +56,8 @@ export function LintModal({
) : ( -
- +
+
)}
@@ -83,7 +83,9 @@ export function LintModal({
@@ -126,8 +131,11 @@ export function LintModal({ {f.file &&

{f.file}

} {f.fixHint && (
- -

{f.fixHint}

+ +

{f.fixHint}

)}
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 90e022d0..1acb2aad 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -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"} @@ -109,7 +109,7 @@ export const PropertyPanel = memo(function PropertyPanel({ {/* Header */}
- {element.selector} + {element.selector}
} diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index b29d267a..9e47a9e7 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -351,7 +351,7 @@ export const NLELayout = memo(function NLELayout({ <> {/* Resize divider */}
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"} diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index 758f518a..09a78b8a 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -90,7 +90,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )} {job.status === "rendering" && (
-
+
)} {job.status === "failed" && ( @@ -122,11 +122,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
{job.stage || "Rendering"} - {job.progress}% + {job.progress}%
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index dd5bb8e1..9207a9a3 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -100,7 +100,7 @@ function AssetCard({ onPointerLeave={() => setHovered(false)} className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isCopied - ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]" + ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" }`} > @@ -127,7 +127,7 @@ function AssetCard({
{name} {isCopied ? ( - Copied! + Copied! ) : ( {asset} )} @@ -164,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport } return (
{ e.preventDefault(); setDragOver(true); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index ecc276d0..e1f7400e 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -41,7 +41,7 @@ function CompCard({ onPointerLeave={handleLeave} className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isActive - ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]" + ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" }`} > diff --git a/packages/studio/src/components/sidebar/LeftSidebar.tsx b/packages/studio/src/components/sidebar/LeftSidebar.tsx index 2632161f..a4665d18 100644 --- a/packages/studio/src/components/sidebar/LeftSidebar.tsx +++ b/packages/studio/src/components/sidebar/LeftSidebar.tsx @@ -82,7 +82,7 @@ export const LeftSidebar = memo(function LeftSidebar({ onClick={() => selectTab("code")} className={`flex-1 py-2 text-[11px] font-medium transition-colors ${ tab === "code" - ? "text-neutral-200 border-b-2 border-[#3CE6AC]" + ? "text-neutral-200 border-b-2 border-studio-accent" : "text-neutral-500 hover:text-neutral-400" }`} > @@ -93,7 +93,7 @@ export const LeftSidebar = memo(function LeftSidebar({ onClick={() => selectTab("compositions")} className={`flex-1 py-2 text-[11px] font-medium transition-colors ${ tab === "compositions" - ? "text-neutral-200 border-b-2 border-blue-500" + ? "text-neutral-200 border-b-2 border-studio-accent" : "text-neutral-500 hover:text-neutral-400" }`} > @@ -104,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({ onClick={() => selectTab("assets")} className={`flex-1 py-2 text-[11px] font-medium transition-colors ${ tab === "assets" - ? "text-neutral-200 border-b-2 border-blue-500" + ? "text-neutral-200 border-b-2 border-studio-accent" : "text-neutral-500 hover:text-neutral-400" }`} > diff --git a/packages/studio/src/player/components/EditModal.tsx b/packages/studio/src/player/components/EditModal.tsx index 234f12e7..753e6f7f 100644 --- a/packages/studio/src/player/components/EditModal.tsx +++ b/packages/studio/src/player/components/EditModal.tsx @@ -105,7 +105,7 @@ Preserve all other elements and timing outside this range.`; {/* Header */}
-
+
{formatTime(start)} — {formatTime(end)} @@ -120,7 +120,7 @@ Preserve all other elements and timing outside this range.`;
{elementsInRange.map((el) => (
- #{el.id} + #{el.id} {el.tag}
))} @@ -141,7 +141,7 @@ Preserve all other elements and timing outside this range.`; }} placeholder="What should change?" rows={2} - className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors" + className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-studio-accent/40 transition-colors" />
@@ -152,11 +152,11 @@ Preserve all other elements and timing outside this range.`; className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${ copied ? "bg-green-500/20 text-green-400 border border-green-500/30" - : "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25" + : "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25" }`} > {copied ? "Copied!" : "Copy to Agent"} - {!copied && Cmd+Enter} + {!copied && Cmd+Enter}
diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index cd53f426..684b9de7 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -257,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"} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 662f8a11..64efd0b6 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -429,7 +429,7 @@ export const Timeline = memo(function Timeline({ return (
{ e.preventDefault(); @@ -466,7 +466,9 @@ export const Timeline = memo(function Timeline({
{isDragOver ? ( @@ -480,13 +482,13 @@ export const Timeline = memo(function Timeline({ strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" - className="text-blue-400 flex-shrink-0" + className="text-studio-accent flex-shrink-0" > - Drop media files to import + Drop media files to import ) : ( <> @@ -568,7 +570,7 @@ export const Timeline = memo(function Timeline({ {/* Shift hint */} {shiftHeld && !rangeSelection && (
- + Drag to select range
diff --git a/packages/studio/tailwind.config.js b/packages/studio/tailwind.config.js index baff0b04..ecdc87de 100644 --- a/packages/studio/tailwind.config.js +++ b/packages/studio/tailwind.config.js @@ -15,7 +15,7 @@ export default { border: "#262626", text: "#e5e5e5", muted: "#737373", - accent: "#00E3FF", + accent: "#3CE6AC", }, }, },