style: apply oxfmt baseline formatting across all source files (#25)

## Summary
- Run `oxfmt .` across the entire codebase to establish formatted baseline
- 299 files changed — mechanical formatting only, no logic changes
- Double quotes, semicolons, 2-space indent, trailing commas, 100 print width

Part 3/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits)

## Test plan
- [x] `pnpm format:check` — all 426 files pass
- [x] `pnpm -r typecheck` — all packages pass
- [x] `pnpm build` — all packages build
- [x] All 348 tests pass
This commit is contained in:
Vance Ingalls
2026-03-23 17:15:14 -07:00
committed by GitHub
parent 323ff8f860
commit 20be2ea1c2
299 changed files with 27750 additions and 16792 deletions
@@ -14,7 +14,15 @@ interface PropertyPanelProps {
onSetText?: (text: string) => void;
}
function PropertyRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
function PropertyRow({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
@@ -28,12 +36,23 @@ function PropertyRow({ label, value, onChange }: { label: string; value: string;
);
}
function ColorRow({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
function ColorRow({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-2xs text-neutral-600 w-16 flex-shrink-0 text-right">{label}</span>
<div className="flex items-center gap-1 flex-1">
<div className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0" style={{ backgroundColor: value }} />
<div
className="w-5 h-5 rounded border border-neutral-700 flex-shrink-0"
style={{ backgroundColor: value }}
/>
<input
type="text"
value={value}
@@ -49,7 +68,9 @@ function SectionHeader({ icon: Icon, label }: { icon: typeof Move; label: string
return (
<div className="flex items-center gap-1.5 mt-2 mb-1">
<Icon size={10} className="text-neutral-600" />
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">{label}</span>
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-wider">
{label}
</span>
</div>
);
}
@@ -98,7 +119,12 @@ export const PropertyPanel = memo(function PropertyPanel({
onClick={isPickMode ? onDisablePick : onEnablePick}
className={isPickMode ? "text-blue-400 bg-blue-500/10" : ""}
/>
<IconButton icon={<X size={11} />} aria-label="Clear selection" size="sm" onClick={onClearPick} />
<IconButton
icon={<X size={11} />}
aria-label="Clear selection"
size="sm"
onClick={onClearPick}
/>
</div>
</div>
@@ -113,10 +139,26 @@ export const PropertyPanel = memo(function PropertyPanel({
{/* Position & Size */}
<SectionHeader icon={Move} label="Position & Size" />
<div className="grid grid-cols-2 gap-1">
<PropertyRow label="X" value={s["left"] ?? "auto"} onChange={(v) => onSetStyle("left", v)} />
<PropertyRow label="Y" value={s["top"] ?? "auto"} onChange={(v) => onSetStyle("top", v)} />
<PropertyRow label="W" value={s["width"] ?? "auto"} onChange={(v) => onSetStyle("width", v)} />
<PropertyRow label="H" value={s["height"] ?? "auto"} onChange={(v) => onSetStyle("height", v)} />
<PropertyRow
label="X"
value={s["left"] ?? "auto"}
onChange={(v) => onSetStyle("left", v)}
/>
<PropertyRow
label="Y"
value={s["top"] ?? "auto"}
onChange={(v) => onSetStyle("top", v)}
/>
<PropertyRow
label="W"
value={s["width"] ?? "auto"}
onChange={(v) => onSetStyle("width", v)}
/>
<PropertyRow
label="H"
value={s["height"] ?? "auto"}
onChange={(v) => onSetStyle("height", v)}
/>
</div>
{/* Typography */}
@@ -127,8 +169,16 @@ export const PropertyPanel = memo(function PropertyPanel({
element.tagName === "h2") && (
<>
<SectionHeader icon={Type} label="Typography" />
<PropertyRow label="Size" value={s["font-size"] ?? ""} onChange={(v) => onSetStyle("font-size", v)} />
<PropertyRow label="Weight" value={s["font-weight"] ?? ""} onChange={(v) => onSetStyle("font-weight", v)} />
<PropertyRow
label="Size"
value={s["font-size"] ?? ""}
onChange={(v) => onSetStyle("font-size", v)}
/>
<PropertyRow
label="Weight"
value={s["font-weight"] ?? ""}
onChange={(v) => onSetStyle("font-weight", v)}
/>
<PropertyRow
label="Family"
value={s["font-family"]?.split(",")[0] ?? ""}
@@ -139,7 +189,11 @@ export const PropertyPanel = memo(function PropertyPanel({
{/* Colors */}
<SectionHeader icon={Palette} label="Colors" />
<ColorRow label="Color" value={s["color"] ?? "#fff"} onChange={(v) => onSetStyle("color", v)} />
<ColorRow
label="Color"
value={s["color"] ?? "#fff"}
onChange={(v) => onSetStyle("color", v)}
/>
<ColorRow
label="Background"
value={s["background-color"] ?? "transparent"}
@@ -148,14 +202,26 @@ export const PropertyPanel = memo(function PropertyPanel({
{/* Appearance */}
<SectionHeader icon={Eye} label="Appearance" />
<PropertyRow label="Opacity" value={s["opacity"] ?? "1"} onChange={(v) => onSetStyle("opacity", v)} />
<PropertyRow
label="Opacity"
value={s["opacity"] ?? "1"}
onChange={(v) => onSetStyle("opacity", v)}
/>
<PropertyRow
label="Radius"
value={s["border-radius"] ?? "0"}
onChange={(v) => onSetStyle("border-radius", v)}
/>
<PropertyRow label="Z-index" value={s["z-index"] ?? "auto"} onChange={(v) => onSetStyle("z-index", v)} />
<PropertyRow label="Transform" value={s["transform"] ?? "none"} onChange={(v) => onSetStyle("transform", v)} />
<PropertyRow
label="Z-index"
value={s["z-index"] ?? "auto"}
onChange={(v) => onSetStyle("z-index", v)}
/>
<PropertyRow
label="Transform"
value={s["transform"] ?? "none"}
onChange={(v) => onSetStyle("transform", v)}
/>
{/* Timing */}
{(element.dataAttributes["start"] || element.dataAttributes["duration"]) && (
@@ -1,5 +1,11 @@
import { useRef, useCallback, memo } from "react";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightActiveLineGutter,
} from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching, foldGutter, indentOnInput } from "@codemirror/language";
@@ -90,12 +96,7 @@ export const SourceEditor = memo(function SourceEditor({
bracketMatching(),
closeBrackets(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
]),
keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap]),
getLanguageExtension(lang),
oneDark,
updateListener,
@@ -32,7 +32,13 @@ export const NLELayout = memo(function NLELayout({
activeCompositionPath,
onIframeRef,
}: NLELayoutProps) {
const { iframeRef, togglePlay, seek, onIframeLoad: baseOnIframeLoad, saveSeekPosition } = useTimelinePlayer();
const {
iframeRef,
togglePlay,
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
} = useTimelinePlayer();
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
const prevRefreshKeyRef = useRef(refreshKey);
@@ -55,7 +61,8 @@ export const NLELayout = memo(function NLELayout({
.then((data: { content?: string }) => {
const html = data.content || "";
const map = new Map<string, string>();
const re = /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
const re =
/data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g;
let match;
while ((match = re.exec(html)) !== null) {
const id = match[1] || match[4];
@@ -98,12 +105,16 @@ export const NLELayout = memo(function NLELayout({
try {
const doc = iframeRef_.current?.contentDocument;
if (doc) {
const host = doc.querySelector(`[data-composition-id="${compId}"][data-composition-src]`);
const host = doc.querySelector(
`[data-composition-id="${compId}"][data-composition-src]`,
);
if (host) {
resolvedPath = host.getAttribute("data-composition-src") || undefined;
}
}
} catch { /* cross-origin */ }
} catch {
/* cross-origin */
}
}
if (!resolvedPath) {
// Strip full URL to relative path if needed
@@ -121,7 +132,11 @@ export const NLELayout = memo(function NLELayout({
return prev.slice(0, -1);
}
// Extract a clean label from the path (strip directories and extension)
const label = resolvedPath.split("/").pop()?.replace(/\.html$/, "") || resolvedPath;
const label =
resolvedPath
.split("/")
.pop()
?.replace(/\.html$/, "") || resolvedPath;
const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`;
return [...prev, { id: resolvedPath, label, previewUrl }];
});
@@ -131,13 +146,10 @@ export const NLELayout = memo(function NLELayout({
);
// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback(
(index: number) => {
usePlayerStore.getState().setElements([]);
setCompositionStack((prev) => prev.slice(0, index + 1));
},
[],
);
const handleNavigateComposition = useCallback((index: number) => {
usePlayerStore.getState().setElements([]);
setCompositionStack((prev) => prev.slice(0, index + 1));
}, []);
// Navigate to a composition when activeCompositionPath changes
const prevActiveCompRef = useRef<string | null>(null);
@@ -145,7 +157,7 @@ export const NLELayout = memo(function NLELayout({
prevActiveCompRef.current = activeCompositionPath;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
if (activeCompositionPath === "index.html") {
setCompositionStack((prev) => prev.length > 1 ? [prev[0]] : prev);
setCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
@@ -229,7 +241,10 @@ export const NLELayout = memo(function NLELayout({
{/* Breadcrumb + Player controls */}
<div className="bg-neutral-950 border-t border-neutral-800/50 flex-shrink-0">
{compositionStack.length > 1 && (
<CompositionBreadcrumb stack={compositionStack} onNavigate={handleNavigateComposition} />
<CompositionBreadcrumb
stack={compositionStack}
onNavigate={handleNavigateComposition}
/>
)}
<PlayerControls onTogglePlay={togglePlay} onSeek={seek} />
</div>
+32 -5
View File
@@ -39,10 +39,14 @@ const variantStyles: Record<ButtonVariant, string> = {
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
"active:scale-[0.98]",
].join(" "),
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(" "),
ghost: ["bg-transparent text-neutral-400", "hover:bg-surface-hover hover:text-white", "active:scale-[0.98]"].join(
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
" ",
),
ghost: [
"bg-transparent text-neutral-400",
"hover:bg-surface-hover hover:text-white",
"active:scale-[0.98]",
].join(" "),
};
const sizeStyles: Record<ButtonSize, string> = {
@@ -52,7 +56,19 @@ const sizeStyles: Record<ButtonSize, string> = {
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = "secondary", size = "md", loading, icon, children, className = "", disabled, ...props }, ref) => {
(
{
variant = "secondary",
size = "md",
loading,
icon,
children,
className = "",
disabled,
...props
},
ref,
) => {
return (
<button
ref={ref}
@@ -70,8 +86,19 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
>
{loading ? (
<svg className="animate-spin h-3.5 w-3.5" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
) : icon ? (
<span className="flex-shrink-0">{icon}</span>
+9 -3
View File
@@ -52,19 +52,25 @@ export function useCodeEditor(): UseCodeEditorReturn {
const updateContent = useCallback(
(content: string) => {
setOpenFiles((prev) =>
prev.map((f) => (f.path === activeFilePath ? { ...f, content, isDirty: content !== f.savedContent } : f)),
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)));
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)),
prev.map((f) =>
f.path === path ? { ...f, savedContent: content, content, isDirty: false } : f,
),
);
}, []);
+18 -5
View File
@@ -99,7 +99,8 @@ export function useElementPicker(
// Accept events from either the primary iframe or the active override
const activeIframe = getActiveIframe();
if (!activeIframe) return;
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow) return;
if (e.source !== activeIframe.contentWindow && e.source !== iframeRef.current?.contentWindow)
return;
if (data.type === "element-picked") {
const el = data.elementInfo;
@@ -192,7 +193,11 @@ export function useElementPicker(
// Persist to source file
if (pickedElement.id) {
// ID-based patching — surgical edit of just the element's style
syncToSource(pickedElement.id, pickedElement.selector, { type: "inline-style", property: prop, value });
syncToSource(pickedElement.id, pickedElement.selector, {
type: "inline-style",
property: prop,
value,
});
} else {
// No ID — save the full composition HTML from the iframe
// This captures ALL inline style changes, not just the targeted one
@@ -203,9 +208,13 @@ export function useElementPicker(
const src = activeIframe.getAttribute("src") ?? "";
const compMatch = src.match(/\/comp\/(.+?)(?:\?|$)/);
const filePath = compMatch ? compMatch[1] : "index.html";
optionsRef.current.onSyncFiles({ [filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}` });
optionsRef.current.onSyncFiles({
[filePath]: `<!DOCTYPE html>\n<html>${fullHtml.replace(/<html[^>]*>/, "")}`,
});
}
} catch { /* cross-origin */ }
} catch {
/* cross-origin */
}
}
}
} catch {
@@ -234,7 +243,11 @@ export function useElementPicker(
);
// Persist to source file immediately
if (pickedElement.id) {
syncToSource(pickedElement.id, pickedElement.selector, { type: "attribute", property: attr, value });
syncToSource(pickedElement.id, pickedElement.selector, {
type: "attribute",
property: attr,
value,
});
}
}
} catch {
@@ -18,33 +18,31 @@ interface AgentActivityTrackProps {
duration: number;
}
export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, duration }: AgentActivityTrackProps) {
export const AgentActivityTrack = memo(function AgentActivityTrack({
agents,
duration,
}: AgentActivityTrackProps) {
if (agents.length === 0 || duration <= 0) return null;
return (
<div className="border-t border-neutral-800/30">
{/* Section header */}
<div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4" /></svg>
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="4" />
</svg>
Agent Activity
</div>
{agents.map((agent) => (
<div
key={agent.agentId}
className="relative flex"
style={{ height: TRACK_H }}
>
<div key={agent.agentId} className="relative flex" style={{ height: TRACK_H }}>
{/* Gutter: agent name */}
<div
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
title={agent.name}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: agent.color }}
/>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: agent.color }} />
</div>
{/* Lane */}
@@ -77,10 +75,7 @@ export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, dur
style={{ left: `${leftPct}%` }}
>
{event.type === "create" ? (
<div
className="w-2 h-2 rotate-45"
style={{ backgroundColor: agent.color }}
/>
<div className="w-2 h-2 rotate-45" style={{ backgroundColor: agent.color }} />
) : (
<div
className="w-1.5 h-1.5 rounded-full"
+104 -94
View File
@@ -11,110 +11,120 @@ interface PlayerProps {
portrait?: boolean;
}
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(({ projectId, directUrl, onLoad, portrait }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const dimsRef = useRef({ w: portrait ? NATIVE_H : NATIVE_W, h: portrait ? NATIVE_W : NATIVE_H });
const [dims, setDims] = useState(dimsRef.current);
const loadCountRef = useRef(0);
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
({ projectId, directUrl, onLoad, portrait }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const dimsRef = useRef({
w: portrait ? NATIVE_H : NATIVE_W,
h: portrait ? NATIVE_W : NATIVE_H,
});
const [dims, setDims] = useState(dimsRef.current);
const loadCountRef = useRef(0);
const updateScale = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const d = dimsRef.current;
setScale(Math.min(rect.width / d.w, rect.height / d.h));
}, []);
const updateScale = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const d = dimsRef.current;
setScale(Math.min(rect.width / d.w, rect.height / d.h));
}, []);
useMountEffect(() => {
updateScale();
const ro = new ResizeObserver(updateScale);
if (containerRef.current) ro.observe(containerRef.current);
useMountEffect(() => {
updateScale();
const ro = new ResizeObserver(updateScale);
if (containerRef.current) ro.observe(containerRef.current);
// Listen for stage-size messages from the runtime
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "stage-size" && data.width > 0 && data.height > 0) {
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
dimsRef.current = { w: data.width, h: data.height };
setDims(dimsRef.current);
updateScale();
}
}
};
window.addEventListener("message", handleMessage);
return () => {
ro.disconnect();
window.removeEventListener("message", handleMessage);
};
});
const handleLoad = useCallback(() => {
loadCountRef.current++;
// Auto-detect dimensions from the composition's data-width/data-height
try {
const iframeEl = typeof ref === "function" ? null : ref?.current;
const doc = iframeEl?.contentDocument;
if (doc) {
const root = doc.querySelector("[data-composition-id]");
if (root) {
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
dimsRef.current = { w: dw, h: dh };
// Listen for stage-size messages from the runtime
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if (
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
data?.type === "stage-size" &&
data.width > 0 &&
data.height > 0
) {
if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
dimsRef.current = { w: data.width, h: data.height };
setDims(dimsRef.current);
// Recalc scale with new dims
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setScale(Math.min(rect.width / dw, rect.height / dh));
updateScale();
}
}
};
window.addEventListener("message", handleMessage);
return () => {
ro.disconnect();
window.removeEventListener("message", handleMessage);
};
});
const handleLoad = useCallback(() => {
loadCountRef.current++;
// Auto-detect dimensions from the composition's data-width/data-height
try {
const iframeEl = typeof ref === "function" ? null : ref?.current;
const doc = iframeEl?.contentDocument;
if (doc) {
const root = doc.querySelector("[data-composition-id]");
if (root) {
const dw = parseInt(root.getAttribute("data-width") || "0", 10);
const dh = parseInt(root.getAttribute("data-height") || "0", 10);
if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
dimsRef.current = { w: dw, h: dh };
setDims(dimsRef.current);
// Recalc scale with new dims
const el = containerRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setScale(Math.min(rect.width / dw, rect.height / dh));
}
}
}
}
} catch {
// Cross-origin
}
} catch {
// Cross-origin
}
if (loadCountRef.current > 1) {
const el = containerRef.current;
if (el) {
el.classList.remove("preview-revealing");
void el.offsetWidth;
el.classList.add("preview-revealing");
const onEnd = () => el.classList.remove("preview-revealing");
el.addEventListener("animationend", onEnd, { once: true });
if (loadCountRef.current > 1) {
const el = containerRef.current;
if (el) {
el.classList.remove("preview-revealing");
void el.offsetWidth;
el.classList.add("preview-revealing");
const onEnd = () => el.classList.remove("preview-revealing");
el.addEventListener("animationend", onEnd, { once: true });
}
}
}
onLoad();
}, [onLoad, ref]);
onLoad();
}, [onLoad, ref]);
return (
<div
ref={containerRef}
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
>
<iframe
ref={ref}
src={directUrl || `/api/projects/${projectId}/preview`}
onLoad={handleLoad}
sandbox="allow-scripts allow-same-origin"
allow="autoplay; fullscreen"
referrerPolicy="no-referrer"
title="Project Preview"
style={{
width: dims.w,
height: dims.h,
border: "none",
transform: `scale(${scale})`,
transformOrigin: "center center",
flexShrink: 0,
}}
/>
</div>
);
});
return (
<div
ref={containerRef}
className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
>
<iframe
ref={ref}
src={directUrl || `/api/projects/${projectId}/preview`}
onLoad={handleLoad}
sandbox="allow-scripts allow-same-origin"
allow="autoplay; fullscreen"
referrerPolicy="no-referrer"
title="Project Preview"
style={{
width: dims.w,
height: dims.h,
border: "none",
transform: `scale(${scale})`,
transformOrigin: "center center",
flexShrink: 0,
}}
/>
</div>
);
},
);
Player.displayName = "Player";
@@ -16,7 +16,11 @@ interface PlayerControlsProps {
onSeek: (time: number) => void;
}
export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, ...overrides }: PlayerControlsProps) {
export const PlayerControls = memo(function PlayerControls({
onTogglePlay,
onSeek,
...overrides
}: PlayerControlsProps) {
// Subscribe to only the fields we render — each selector prevents cascading re-renders
const storeIsPlaying = usePlayerStore((s) => s.isPlaying);
const storeDuration = usePlayerStore((s) => s.duration);
@@ -165,9 +169,14 @@ export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSee
{SPEED_OPTIONS.map((rate) => (
<button
key={rate}
onClick={() => { setPlaybackRate(rate); setShowSpeedMenu(false); }}
onClick={() => {
setPlaybackRate(rate);
setShowSpeedMenu(false);
}}
className={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
rate === playbackRate ? "text-white bg-neutral-800" : "text-neutral-400 hover:text-white hover:bg-neutral-800"
rate === playbackRate
? "text-white bg-neutral-800"
: "text-neutral-400 hover:text-white hover:bg-neutral-800"
}`}
>
{rate}x
@@ -36,7 +36,6 @@ export function PreviewPanel({
renderStatus,
children,
}: PreviewPanelProps) {
const renderState = renderStatus?.state ?? "idle";
return (
@@ -52,71 +51,102 @@ export function PreviewPanel({
{hasProject && projectId ? (
<>
{/* Player — takes all remaining space, constrained for portrait */}
<div className="flex items-center justify-center p-2 overflow-hidden" style={{ minHeight: 0, minWidth: 0 }}>
<Player ref={iframeRef} projectId={projectId} onLoad={onIframeLoad} portrait={portrait} />
<div
className="flex items-center justify-center p-2 overflow-hidden"
style={{ minHeight: 0, minWidth: 0 }}
>
<Player
ref={iframeRef}
projectId={projectId}
onLoad={onIframeLoad}
portrait={portrait}
/>
</div>
{/* Controls — fixed height */}
<div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
<PlayerControls
onTogglePlay={onTogglePlay}
onSeek={onSeek}
/>
<PlayerControls onTogglePlay={onTogglePlay} onSeek={onSeek} />
</div>
{/* Timeline — capped height, internal scroll */}
<div className="bg-neutral-950 flex-shrink-0 overflow-y-auto" style={{ maxHeight: "100px" }}>
<div
className="bg-neutral-950 flex-shrink-0 overflow-y-auto"
style={{ maxHeight: "100px" }}
>
<Timeline onSeek={onSeek} />
</div>
{/* Render status — only shown when actively rendering, complete, or error */}
{renderStatus && (renderState === "rendering" || renderState === "complete" || renderState === "error") && (
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
{renderState === "rendering" && (
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
style={{ width: `${renderStatus.progress ?? 0}%` }}
/>
{renderStatus &&
(renderState === "rendering" ||
renderState === "complete" ||
renderState === "error") && (
<div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
{renderState === "rendering" && (
<div className="flex-1">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
style={{ width: `${renderStatus.progress ?? 0}%` }}
/>
</div>
<span className="text-xs text-neutral-400 flex-shrink-0">
{renderStatus.stage || "Rendering..."}
</span>
</div>
<span className="text-xs text-neutral-400 flex-shrink-0">
{renderStatus.stage || "Rendering..."}
</span>
</div>
</div>
)}
{renderState === "complete" && (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>Complete</span>
</div>
)}
{renderState === "error" && (
<div className="flex items-center gap-2 text-xs text-red-400">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className="truncate">{renderStatus.error}</span>
{renderStatus.onRender && (
<button
type="button"
onClick={renderStatus.onRender}
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
)}
{renderState === "complete" && (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
Retry
</button>
)}
</div>
)}
</div>
)}
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>Complete</span>
</div>
)}
{renderState === "error" && (
<div className="flex items-center gap-2 text-xs text-red-400">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className="truncate">{renderStatus.error}</span>
{renderStatus.onRender && (
<button
type="button"
onClick={renderStatus.onRender}
className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
>
Retry
</button>
)}
</div>
)}
</div>
)}
{/* Optional custom slot */}
{children}
@@ -140,7 +170,9 @@ export function PreviewPanel({
</svg>
</div>
<p className="text-sm text-neutral-600">Preview will appear here</p>
<p className="text-xs text-neutral-700 mt-1">Send a message to generate a video composition</p>
<p className="text-xs text-neutral-700 mt-1">
Send a message to generate a video composition
</p>
</div>
</div>
)}
@@ -25,7 +25,16 @@ interface TrackStyle {
/* ── Icons from Figma HyperFrames design system ── */
const ICON_BASE = "/icons/timeline";
function TimelineIcon({ src }: { src: string }) {
return <img src={src} alt="" width={12} height={12} style={{ filter: "brightness(0) invert(1)" }} draggable={false} />;
return (
<img
src={src}
alt=""
width={12}
height={12}
style={{ filter: "brightness(0) invert(1)" }}
draggable={false}
/>
);
}
const IconCaptions = <TimelineIcon src={`${ICON_BASE}/captions.svg`} />;
const IconImage = <TimelineIcon src={`${ICON_BASE}/image.svg`} />;
@@ -125,7 +134,9 @@ function generateTicks(duration: number): { major: number[]; minor: number[] } {
const minor: number[] = [];
for (let t = 0; t <= duration + 0.001; t += minorInterval) {
const rounded = Math.round(t * 100) / 100;
const isMajor = Math.abs(rounded % majorInterval) < 0.01 || Math.abs(rounded % majorInterval - majorInterval) < 0.01;
const isMajor =
Math.abs(rounded % majorInterval) < 0.01 ||
Math.abs((rounded % majorInterval) - majorInterval) < 0.01;
if (isMajor) major.push(rounded);
else minor.push(rounded);
}
@@ -198,10 +209,14 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
[seekFromX],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => { if (isDragging.current) seekFromX(e.clientX); },
(e: React.PointerEvent) => {
if (isDragging.current) seekFromX(e.clientX);
},
[seekFromX],
);
const handlePointerUp = useCallback(() => { isDragging.current = false; }, []);
const handlePointerUp = useCallback(() => {
isDragging.current = false;
}, []);
const tracks = useMemo(() => {
const map = new Map<number, typeof elements>();
@@ -226,7 +241,11 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
if (!timelineReady) return null;
if (elements.length === 0) {
return <div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">No timeline elements</div>;
return (
<div className="px-3 py-3 text-2xs text-neutral-600 border-t border-neutral-800/50">
No timeline elements
</div>
);
}
const totalH = RULER_H + tracks.length * TRACK_H;
@@ -243,22 +262,48 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
>
<div className="relative" style={{ height: totalH }}>
{/* Grid lines */}
<svg className="absolute pointer-events-none" style={{ left: GUTTER }} width={`calc(100% - ${GUTTER}px)`} height={totalH}>
<svg
className="absolute pointer-events-none"
style={{ left: GUTTER }}
width={`calc(100% - ${GUTTER}px)`}
height={totalH}
>
{major.map((t) => (
<line key={`g-${t}`} x1={`${(t / duration) * 100}%`} y1={RULER_H} x2={`${(t / duration) * 100}%`} y2={totalH} stroke="rgba(255,255,255,0.035)" strokeWidth="1" />
<line
key={`g-${t}`}
x1={`${(t / duration) * 100}%`}
y1={RULER_H}
x2={`${(t / duration) * 100}%`}
y2={totalH}
stroke="rgba(255,255,255,0.035)"
strokeWidth="1"
/>
))}
</svg>
{/* Ruler */}
<div className="relative border-b border-neutral-800/40" style={{ height: RULER_H, marginLeft: GUTTER }}>
<div
className="relative border-b border-neutral-800/40"
style={{ height: RULER_H, marginLeft: GUTTER }}
>
{minor.map((t) => (
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: `${(t / duration) * 100}%` }}>
<div
key={`m-${t}`}
className="absolute bottom-0"
style={{ left: `${(t / duration) * 100}%` }}
>
<div className="w-px h-[3px] bg-neutral-700/40" />
</div>
))}
{major.map((t) => (
<div key={`M-${t}`} className="absolute bottom-0 flex flex-col items-center" style={{ left: `${(t / duration) * 100}%` }}>
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">{formatTick(t)}</span>
<div
key={`M-${t}`}
className="absolute bottom-0 flex flex-col items-center"
style={{ left: `${(t / duration) * 100}%` }}
>
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
{formatTick(t)}
</span>
<div className="w-px h-[5px] bg-neutral-600/60" />
</div>
))}
@@ -268,9 +313,16 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
{tracks.map(([trackNum, els]) => {
const ts = trackStyles.get(trackNum) ?? DEFAULT;
return (
<div key={trackNum} className="relative flex" style={{ height: TRACK_H, backgroundColor: ts.row }}>
<div
key={trackNum}
className="relative flex"
style={{ height: TRACK_H, backgroundColor: ts.row }}
>
{/* Gutter: colored icon badge (Figma HyperFrames style) */}
<div className="flex-shrink-0 flex items-center justify-center" style={{ width: GUTTER }}>
<div
className="flex-shrink-0 flex items-center justify-center"
style={{ width: GUTTER }}
>
<div
className="flex items-center justify-center"
style={{
@@ -314,7 +366,9 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
backgroundImage: isComposition
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
: undefined,
border: isSelected ? `2px solid rgba(255,255,255,0.9)` : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
border: isSelected
? `2px solid rgba(255,255,255,0.9)`
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
boxShadow: isSelected
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
: isBeingEdited
@@ -327,9 +381,11 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
transform: isHovered && !isSelected ? "scaleY(1.04)" : "scaleY(1)",
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
}}
title={isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`}
title={
isComposition
? `${el.compositionSrc} \u2022 Double-click to open`
: `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
}
onPointerEnter={() => setHoveredClip(clipKey)}
onPointerLeave={() => setHoveredClip(null)}
onPointerDown={(e) => e.stopPropagation()}
@@ -370,8 +426,19 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
}}
>
{/* Mini cursor arrow */}
<svg width="8" height="10" viewBox="0 0 12 16" fill="none" style={{ flexShrink: 0 }}>
<path d="M1 1L11 7L6 8L4 14L1 1Z" fill={activeEdit.agentColor} stroke="white" strokeWidth="0.8" />
<svg
width="8"
height="10"
viewBox="0 0 12 16"
fill="none"
style={{ flexShrink: 0 }}
>
<path
d="M1 1L11 7L6 8L4 14L1 1Z"
fill={activeEdit.agentColor}
stroke="white"
strokeWidth="0.8"
/>
</svg>
<span
className="text-[8px] font-semibold px-1 py-px rounded whitespace-nowrap"
@@ -416,13 +483,15 @@ export const Timeline = memo(function Timeline({ onSeek, onDrillDown }: Timeline
>
<div className="absolute top-0 bottom-0 left-1/2 -translate-x-1/2 w-px bg-white/90" />
<div className="absolute left-1/2 -translate-x-1/2" style={{ top: 0 }}>
<div style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: "7px solid rgba(255,255,255,0.95)",
}} />
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: "7px solid rgba(255,255,255,0.95)",
}}
/>
</div>
</div>
</div>
@@ -119,7 +119,9 @@ function autoHealMissingCompositionIds(doc: Document): void {
for (const compId of referencedIds) {
if (compId === "root" || existingIds.has(compId)) continue;
const host =
doc.getElementById(`${compId}-layer`) || doc.getElementById(`${compId}-comp`) || doc.getElementById(compId);
doc.getElementById(`${compId}-layer`) ||
doc.getElementById(`${compId}-comp`) ||
doc.getElementById(compId);
if (!host) continue;
if (!host.getAttribute("data-composition-id")) {
host.setAttribute("data-composition-id", compId);
@@ -205,19 +207,30 @@ export function useTimelinePlayer() {
const iframe = iframeRef.current;
if (!iframe) return;
// Send to runtime via bridge (works with both new and CDN runtime)
iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
iframe.contentWindow?.postMessage({ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate }, "*");
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
"*",
);
iframe.contentWindow?.postMessage(
{ source: "hf-parent", type: "control", action: "set-playback-rate", playbackRate: rate },
"*",
);
// Also set directly on GSAP timeline if accessible
try {
const win = iframe.contentWindow as IframeWindow | null;
if (win?.__timelines) {
for (const tl of Object.values(win.__timelines)) {
if (tl && typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function") {
if (
tl &&
typeof (tl as unknown as { timeScale?: (v: number) => void }).timeScale === "function"
) {
(tl as unknown as { timeScale: (v: number) => void }).timeScale(rate);
}
}
}
} catch { /* cross-origin */ }
} catch {
/* cross-origin */
}
}, []);
const play = useCallback(() => {
@@ -388,13 +401,22 @@ export function useTimelinePlayer() {
console.warn("Could not find __player, __timeline, or __timelines on iframe after 5s");
}
}, 200);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setElements is a stable zustand setter
}, [getAdapter, setDuration, setCurrentTime, setTimelineReady, setIsPlaying, processTimelineMessage]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- setElements is a stable zustand setter
}, [
getAdapter,
setDuration,
setCurrentTime,
setTimelineReady,
setIsPlaying,
processTimelineMessage,
]);
/** Save the current playback time so the next onIframeLoad restores it. */
const saveSeekPosition = useCallback(() => {
const adapter = getAdapter();
pendingSeekRef.current = adapter ? adapter.getTime() : (usePlayerStore.getState().currentTime ?? 0);
pendingSeekRef.current = adapter
? adapter.getTime()
: (usePlayerStore.getState().currentTime ?? 0);
isRefreshingRef.current = true;
stopRAFLoop();
setIsPlaying(false);
@@ -430,7 +452,11 @@ export function useTimelinePlayer() {
// so we get the complete clip list (not just the first few).
const handleMessage = (e: MessageEvent) => {
const data = e.data;
if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "timeline" && Array.isArray(data.clips)) {
if (
(data?.source === "hf-preview" || data?.source === "hf-preview") &&
data?.type === "timeline" &&
Array.isArray(data.clips)
) {
processTimelineMessageRef.current(data);
// Update duration only if the new value is longer (don't downgrade during generation)
if (data.durationInFrames > 0) {
+5 -1
View File
@@ -48,7 +48,11 @@ export function resolveSourceFile(
if (classMatch) {
const cls = classMatch[1];
for (const [path, content] of Object.entries(files)) {
if (content.includes(`class="${cls}"`) || content.includes(`class="${cls} `) || content.includes(` ${cls}"`)) {
if (
content.includes(`class="${cls}"`) ||
content.includes(`class="${cls} `) ||
content.includes(` ${cls}"`)
) {
return path;
}
}