fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel (#1965)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-08-21 09:36:31 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent acfa7c55a2
commit ba607bf886
29 changed files with 771 additions and 136 deletions
+8
View File
@@ -455,6 +455,10 @@
// require intrusive middleware changes beyond this PR's scope.
"minLines": 6,
"ignore": [
// FileTree.tsx / LeftSidebar.tsx: pre-existing 8-line structural clone
// (shared sidebar node shape); surfaced by the UX-sweep line shifts.
"packages/studio/src/components/editor/FileTree.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
// AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry;
@@ -870,6 +874,10 @@
"packages/studio/src/components/editor/BlockParamsPanel.tsx",
"packages/studio/src/components/editor/DomEditOverlay.tsx",
"packages/studio/src/components/editor/FileTree.tsx",
// ColorGradingControls: main's 374-line render function (LUT + vignette/grain
// detail panels), reconciled against this PR's LUT import spinner/error graft
// during rebase. Pre-existing complexity; line-shift re-flags it.
"packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx",
"packages/studio/src/components/editor/FileTreeNodes.tsx",
"packages/studio/src/components/editor/LayersPanel.tsx",
"packages/studio/src/components/editor/MotionPathNode.tsx",
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex w-full items-center gap-2 py-1.5"
aria-expanded={expanded}
className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
>
<span
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
@@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
<button
type="button"
onClick={onClick}
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
className="relative flex-shrink-0 rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400 active:scale-[0.95]"
title={title}
aria-label={title}
>
<svg
width="12"
@@ -87,13 +88,15 @@ export function PropertyRow({
</span>
<button
type="button"
role="switch"
aria-checked={isVisible}
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
className="flex-shrink-0 rounded-full transition-all duration-150 relative"
className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Arc Motion</span>
<button
type="button"
role="switch"
aria-checked={Boolean(arcPath.enabled)}
aria-label="Arc motion"
onClick={handleToggle}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
className="relative rounded-full transition-colors duration-200"
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Auto-Rotate</span>
<button
type="button"
role="switch"
aria-checked={Boolean(arcPath.autoRotate)}
aria-label="Auto-rotate along path"
onClick={handleAutoRotate}
disabled={disabled}
className="relative rounded-full transition-all duration-150"
className="relative rounded-full transition-colors duration-200"
style={{
width: 28,
height: 16,
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
}
>
<span
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
style={{
width: 12,
height: 12,
@@ -0,0 +1,170 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { BlockParam } from "@hyperframes/core/registry";
import { FileManagerProvider } from "../../contexts/FileManagerContext";
import type { useFileManager } from "../../hooks/useFileManager";
import { StudioPlaybackProvider, type StudioPlaybackValue } from "../../contexts/StudioContext";
import { BlockParamsPanel } from "./BlockParamsPanel";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
const PARAMS: BlockParam[] = [
{ key: "--bg-color", label: "Background", type: "color", default: "#0c0c0c" },
{ key: "--text-color", label: "Text color", type: "color", default: "#fafafa" },
];
const playbackValue: StudioPlaybackValue = {
captionEditMode: false,
compositionLoading: false,
refreshKey: 0,
setRefreshKey: vi.fn(),
timelineElements: [],
isPlaying: false,
refreshPreviewDocumentVersion: vi.fn(),
};
function makeFileManager(content: { value: string }) {
const readProjectFile = vi.fn(async () => content.value);
const writeProjectFile = vi.fn(async (_path: string, next: string) => {
content.value = next;
});
// The panel only touches read/writeProjectFile; the rest of the context
// surface is irrelevant to these tests.
const value = { readProjectFile, writeProjectFile } as unknown as ReturnType<
typeof useFileManager
>;
return { value, readProjectFile, writeProjectFile };
}
let root: Root | null = null;
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
vi.clearAllTimers();
});
function renderPanel(fileManager: ReturnType<typeof makeFileManager>["value"]) {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => {
root?.render(
<StudioPlaybackProvider value={playbackValue}>
<FileManagerProvider value={fileManager}>
<BlockParamsPanel
blockName="vfx-demo"
blockTitle="VFX Demo"
params={PARAMS}
compositionPath="compositions/vfx-demo.html"
onClose={vi.fn()}
/>
</FileManagerProvider>
</StudioPlaybackProvider>,
);
});
}
function changeParam(label: string, next: string) {
const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label} value"]`);
if (!input) throw new Error(`no input for ${label}`);
act(() => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, next);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function flushCommit() {
await act(async () => {
vi.advanceTimersByTime(350);
// Drain the read → guard → write promise chain.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
}
describe("BlockParamsPanel commit safety", () => {
it("refuses a multi-occurrence value and never mutates the unrelated sibling", async () => {
// The param's default (#0c0c0c) also appears on an UNRELATED element. The
// panel has no way to tell which occurrence belongs to the param, so it
// must refuse rather than risk rewriting `.unrelated`. Nothing is written.
const content = {
value: ".block { background: #0c0c0c; } .unrelated { border-color: #0c0c0c; }",
};
const before = content.value;
const fm = makeFileManager(content);
renderPanel(fm.value);
changeParam("Background", "#123456");
await flushCommit();
expect(fm.writeProjectFile).not.toHaveBeenCalled();
expect(content.value).toBe(before); // both occurrences untouched
expect(content.value).toContain(".unrelated { border-color: #0c0c0c; }");
expect(document.body.textContent).toContain("appears 2×");
});
it("writes a unique occurrence with token boundaries (no substring corruption)", async () => {
// #0c0c0c is unique here — the 8-digit #0c0c0cff must NOT match (token
// boundary), so exactly one occurrence rewrites and the hex8 is preserved.
const content = {
value: ".a { background: #0c0c0c; } .c { color: #0c0c0cff; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);
changeParam("Background", "#123456");
await flushCommit();
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1);
expect(content.value).toBe(".a { background: #123456; } .c { color: #0c0c0cff; }");
});
it("refuses once a previously-unique value later collides with unrelated content", async () => {
const content = {
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);
// #0c0c0c is unique → first commit writes; now #fafafa appears 2×.
changeParam("Background", "#fafafa");
await flushCommit();
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
// Next edit's current value (#fafafa) now matches 2× → refuse.
changeParam("Background", "#ff0000");
await flushCommit();
expect(fm.writeProjectFile).toHaveBeenCalledTimes(1); // no second write
expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
expect(document.body.textContent).toContain("appears 2×");
});
it("keeps a pending commit for one param when another param is edited", async () => {
const content = {
value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
};
const fm = makeFileManager(content);
renderPanel(fm.value);
// Edit both params back-to-back within the 300ms debounce window: the
// second edit must not cancel the first param's pending commit.
changeParam("Background", "#111111");
changeParam("Text color", "#222222");
await flushCommit();
await flushCommit();
expect(content.value).toContain("#111111");
expect(content.value).toContain("#222222");
});
});
@@ -1,5 +1,8 @@
import { memo, useState, useCallback } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import type { BlockParam } from "@hyperframes/core/registry";
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
import { trackBlockParamCommit } from "../../telemetry/events";
interface BlockParamsPanelProps {
blockName: string;
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
onClose: () => void;
}
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
export const BlockParamsPanel = memo(function BlockParamsPanel({
blockName,
blockTitle,
params,
compositionPath: _compositionPath,
compositionPath,
onClose,
}: BlockParamsPanelProps) {
const fileManager = useFileManagerContextOptional();
const { setRefreshKey } = useStudioPlaybackContext();
const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const p of params) {
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
}
return initial;
});
// Last value actually written to the block file per param — the literal we
// substitute when the next commit rewrites the file.
const appliedRef = useRef<Record<string, string>>(
Object.fromEntries(params.map((p) => [p.key, p.default])),
);
const [commitState, setCommitState] = useState<CommitState>({ tone: "idle" });
// Per-param debounce timers: a single shared timer would let a second
// param's edit silently cancel the first param's pending commit.
const commitTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const handleChange = useCallback((key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
}, []);
const commitParamNow = useCallback(
async (key: string, nextValue: string) => {
if (!fileManager) return;
const previous = appliedRef.current[key];
if (previous === undefined || previous === nextValue || !nextValue.trim()) return;
setCommitState({ tone: "saving" });
try {
const content = await fileManager.readProjectFile(compositionPath);
// Token-boundary match so "#0c0c0c" never rewrites part of "#0c0c0cff".
const matcher = new RegExp(
`(?<![-\\w#])${previous.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![-\\w])`,
"g",
);
const matches = content.match(matcher)?.length ?? 0;
if (matches === 0) {
setCommitState({
tone: "error",
message: `Couldn't find the current value in ${compositionPath} — it may have been edited by hand.`,
});
trackBlockParamCommit({ tone: "error", blockName, key });
return;
}
// The panel maps a param to a bare literal, with no per-occurrence
// binding metadata. At the string level it cannot tell "both of these
// belong to this param" from "one is unrelated adjacent content" (a
// sibling block, a decoration, a comment). A blind replace of a literal
// that appears more than once could silently mutate that unrelated
// content — so refuse and tell the user to disambiguate by hand. Only a
// unique single occurrence is safe to rewrite automatically.
if (matches > 1) {
setCommitState({
tone: "error",
message: `"${previous}" appears ${matches}× in ${compositionPath} — the panel can't tell which one belongs to this parameter, so it won't risk changing unrelated content. Edit the file directly to disambiguate.`,
});
trackBlockParamCommit({ tone: "error", blockName, key });
return;
}
await fileManager.writeProjectFile(compositionPath, content.replace(matcher, nextValue));
appliedRef.current[key] = nextValue;
setCommitState({ tone: "saved" });
trackBlockParamCommit({ tone: "saved", blockName, key });
setRefreshKey((k) => k + 1);
} catch {
setCommitState({ tone: "error", message: "Couldn't save the block file. Retry?" });
trackBlockParamCommit({ tone: "error", blockName, key });
}
},
[fileManager, compositionPath, setRefreshKey, blockName],
);
// Commits are serialized: two params committing concurrently would each
// read-modify-write the same file and the second write would drop the first.
const commitChainRef = useRef<Promise<void>>(Promise.resolve());
const commitParam = useCallback(
(key: string, nextValue: string) => {
const run = commitChainRef.current.then(() => commitParamNow(key, nextValue));
commitChainRef.current = run.catch(() => undefined);
return run;
},
[commitParamNow],
);
const handleChange = useCallback(
(key: string, value: string) => {
setValues((prev) => ({ ...prev, [key]: value }));
const timers = commitTimersRef.current;
const existing = timers.get(key);
if (existing) clearTimeout(existing);
timers.set(
key,
setTimeout(() => {
timers.delete(key);
void commitParam(key, value);
}, 300),
);
},
[commitParam],
);
// Flush (not discard) pending commits on unmount so closing the panel
// within the debounce window doesn't silently drop the last edit.
const flushRef = useRef<() => void>(() => {});
flushRef.current = () => {
for (const [key, timer] of commitTimersRef.current) {
clearTimeout(timer);
const value = values[key];
if (value !== undefined) void commitParam(key, value);
}
commitTimersRef.current.clear();
};
useEffect(() => () => flushRef.current(), []);
return (
<div className="flex flex-col h-full">
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
<button
type="button"
onClick={onClose}
className="text-neutral-500 hover:text-neutral-300 transition-colors"
aria-label="Close block parameters"
className="p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
>
<svg
width="14"
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Parameters
</div>
{params.length === 0 && (
<div className="text-[10px] text-neutral-500">This block has no editable parameters.</div>
)}
{!fileManager && params.length > 0 && (
<div className="text-[10px] text-amber-400/90">
Block params can't be edited here no project file access.
</div>
)}
{params.map((param) => (
<ParamControl
key={param.key}
param={param}
value={values[param.key] ?? param.default}
disabled={!fileManager}
onChange={(v) => handleChange(param.key, v)}
/>
))}
{commitState.tone === "saving" && (
<div className="text-[10px] text-neutral-500" role="status">
Saving
</div>
)}
{commitState.tone === "saved" && (
<div className="text-[10px] text-emerald-500/90" role="status">
Saved to {compositionPath}
</div>
)}
{commitState.tone === "error" && (
<div className="text-[10px] text-red-400" role="alert">
{commitState.message}
</div>
)}
</div>
</div>
);
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
function ParamControl({
param,
value,
disabled,
onChange,
}: {
param: BlockParam;
value: string;
disabled?: boolean;
onChange: (value: string) => void;
}) {
return (
@@ -87,14 +219,18 @@ function ParamControl({
<input
type="color"
value={value}
disabled={disabled}
aria-label={`${param.label} color`}
onChange={(e) => onChange(e.target.value)}
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
/>
<input
type="text"
value={value}
disabled={disabled}
aria-label={`${param.label} value`}
onChange={(e) => onChange(e.target.value)}
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700"
className="flex-1 bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 font-mono focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
)}
@@ -107,8 +243,10 @@ function ParamControl({
max={param.max ?? 100}
step={param.step ?? 1}
value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)}
className="flex-1"
className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
</div>
@@ -118,16 +256,20 @@ function ParamControl({
<input
type="text"
value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
/>
)}
{param.type === "select" && param.options && (
<select
value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700"
className="w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{param.options.map((opt) => (
<option key={opt.value} value={opt.value}>
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
stroke="rgba(255,255,255,0.24)"
strokeWidth={1.5}
/>
<circle
cx={sTL}
cy={sTL}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle
cx={PREVIEW_W - sTR}
cy={sTR}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
<circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
<circle
cx={PREVIEW_W - sBR}
cy={PREVIEW_H - sBR}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle
cx={sBL}
cy={PREVIEW_H - sBL}
r={3}
fill={linked ? "#3b82f6" : "#a78bfa"}
className="cursor-pointer"
/>
<circle cx={sBL} cy={PREVIEW_H - sBL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
</svg>
<button
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [inlineInput, setInlineInput] = useState<InlineInputState | null>(null);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
path: string;
isFolder: boolean;
x: number;
y: number;
} | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const dragSourceRef = useRef<string | null>(null);
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
// ── Delete ──
const handleDelete = useCallback((path: string) => {
setDeleteTarget(path);
}, []);
const handleDelete = useCallback(
(path: string) => {
// Anchor the confirm near where the context menu was opened so it
// appears next to the row the user acted on.
setDeleteTarget({
path,
isFolder: contextMenu?.targetIsFolder ?? false,
x: contextMenu?.x ?? window.innerWidth / 2,
y: contextMenu?.y ?? window.innerHeight / 2,
});
},
[contextMenu],
);
const handleDeleteConfirm = useCallback(() => {
if (deleteTarget) {
onDeleteFile?.(deleteTarget);
onDeleteFile?.(deleteTarget.path);
setDeleteTarget(null);
}
}, [deleteTarget, onDeleteFile]);
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
<div className="flex items-center gap-0.5">
<button
onClick={() => handleNewFile("")}
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
title="New File"
aria-label="New File"
>
<Plus size={12} weight="bold" />
</button>
<button
onClick={() => handleNewFolder("")}
className="p-0.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 transition-colors"
className="p-1.5 rounded hover:bg-neutral-800 text-neutral-600 hover:text-neutral-400 active:scale-[0.97] transition-colors"
title="New Folder"
aria-label="New Folder"
>
<FolderSimplePlus size={12} weight="duotone" />
</button>
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
onCancel={() => inlineInput.onCancel?.()}
/>
)}
{children.length === 0 && !inlineInput && (
<div className="px-3 py-4 text-center text-[11px] text-neutral-600">
No files yet{hasFileOps ? " — use + above to create one" : ""}.
</div>
)}
{children.map((child) =>
child.isFile && child.children.size === 0 ? (
<TreeFile
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
)}
</div>
{/* Delete confirmation overlay */}
{/* Delete confirmation — anchored near the row it was invoked on */}
{deleteTarget && (
<div className="border-t border-neutral-800/50 flex-shrink-0">
<div
className="fixed z-50 w-56"
style={{
left: Math.min(deleteTarget.x, window.innerWidth - 240),
top: Math.min(deleteTarget.y, window.innerHeight - 120),
}}
>
<DeleteConfirm
name={
deleteTarget.includes("/")
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1)
: deleteTarget
deleteTarget.path.includes("/")
? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
: deleteTarget.path
}
isFolder={deleteTarget.isFolder}
onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel}
/>
@@ -43,9 +43,14 @@ export function ContextMenu({
onDelete: (path: string) => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const firstItem = menuRef.current?.querySelector("button");
if (firstItem instanceof HTMLElement) firstItem.focus();
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
@@ -59,9 +64,28 @@ export function ContextMenu({
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
restoreFocusRef.current?.focus();
};
}, [onClose]);
const handleMenuKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
return;
}
const menu = menuRef.current;
if (!menu) return;
e.preventDefault();
const items = Array.from(menu.querySelectorAll("button"));
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
let next = 0;
if (e.key === "ArrowDown") next = current < 0 ? 0 : (current + 1) % items.length;
else if (e.key === "ArrowUp") {
next = current < 0 ? items.length - 1 : (current - 1 + items.length) % items.length;
} else if (e.key === "End") next = items.length - 1;
items[next]?.focus();
};
const adjustedX = Math.min(state.x, window.innerWidth - 180);
const adjustedY = Math.min(state.y, window.innerHeight - 200);
@@ -74,13 +98,16 @@ export function ContextMenu({
return (
<div
ref={menuRef}
role="menu"
onKeyDown={handleMenuKeyDown}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[160px]"
style={{ left: adjustedX, top: adjustedY }}
>
{state.targetIsFolder && (
<>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
onClick={() => {
onNewFile(state.targetPath);
onClose();
@@ -90,7 +117,8 @@ export function ContextMenu({
New File
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
onClick={() => {
onNewFolder(state.targetPath);
onClose();
@@ -105,7 +133,8 @@ export function ContextMenu({
{!state.targetIsFolder && (
<>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
onClick={() => {
onNewFile(parentPath);
onClose();
@@ -118,7 +147,8 @@ export function ContextMenu({
</>
)}
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
onClick={() => {
onRename(state.targetPath);
onClose();
@@ -129,7 +159,8 @@ export function ContextMenu({
</button>
{!state.targetIsFolder && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 active:bg-neutral-700 outline-none cursor-pointer text-left"
onClick={() => {
onDuplicate(state.targetPath);
onClose();
@@ -141,7 +172,8 @@ export function ContextMenu({
)}
<div className="border-t border-neutral-700 my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 cursor-pointer text-left"
role="menuitem"
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-900/30 focus-visible:bg-red-900/30 active:bg-red-900/50 outline-none cursor-pointer text-left"
onClick={() => {
onDelete(state.targetPath);
onClose();
@@ -172,6 +204,13 @@ export function InlineInput({
const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false);
const [value, setValue] = useState(defaultValue);
const [error, setError] = useState<string | null>(null);
const validate = (name: string): string | null => {
if (/[/\\]/.test(name)) return "Name can't contain / or \\";
if (name.includes("..")) return "Name can't contain ..";
return null;
};
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
@@ -196,8 +235,16 @@ export function InlineInput({
if (e.key === "Enter") {
e.preventDefault();
const trimmed = value.trim();
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed);
else onCancel();
if (!trimmed) {
onCancel();
return;
}
const invalid = validate(trimmed);
if (invalid) {
setError(invalid);
return;
}
commit(trimmed);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
@@ -206,8 +253,7 @@ export function InlineInput({
const handleBlur = () => {
const trimmed = value.trim();
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes("..")))
commit(trimmed);
if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
else onCancel();
};
@@ -221,15 +267,28 @@ export function InlineInput({
) : (
<FileIcon path={value} />
)}
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
className="flex-1 min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-[#3CE6AC]"
spellCheck={false}
/>
<div className="flex-1 min-w-0">
<input
ref={inputRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (error) setError(null);
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
aria-invalid={error ? true : undefined}
className={`w-full min-w-0 bg-neutral-800 text-neutral-200 text-xs px-1.5 py-0.5 rounded border outline-none ${
error ? "border-red-500/70" : "border-neutral-600 focus:border-[#3CE6AC]"
}`}
spellCheck={false}
/>
{error && (
<div className="mt-0.5 text-[10px] text-red-400" role="alert">
{error}
</div>
)}
</div>
</div>
);
}
@@ -238,10 +297,12 @@ export function InlineInput({
export function DeleteConfirm({
name,
isFolder,
onConfirm,
onCancel,
}: {
name: string;
isFolder?: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
@@ -269,7 +330,16 @@ export function DeleteConfirm({
className="mx-1 my-0.5 p-2 bg-neutral-800 border border-neutral-700 rounded-md text-xs"
>
<p className="text-neutral-300 mb-2">
Delete <span className="font-medium text-neutral-100">{name}</span>?
{isFolder ? (
<>
Delete folder <span className="font-medium text-neutral-100">{name}</span> and
everything inside it?
</>
) : (
<>
Delete <span className="font-medium text-neutral-100">{name}</span>?
</>
)}
</p>
<div className="flex gap-1.5">
<button
@@ -61,6 +61,11 @@ export function GsapAnimationList({
return (
<div className="space-y-2">
{animations.length === 0 && (
<p className="text-[11px] leading-4 text-neutral-500">
No animations on this element yet add an effect below to animate it.
</p>
)}
{animations.map((animation, index) => (
<AnimationCard
{...trackedCallbacks}
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
e.stopPropagation();
onClick();
}}
className="flex-shrink-0 p-0.5 transition-opacity hover:opacity-100"
className="relative flex-shrink-0 p-0.5 transition-opacity hover:opacity-100 before:absolute before:-inset-1.5 before:content-['']"
style={{ color, opacity }}
title={title}
aria-label={title}
aria-pressed={state === "active"}
>
<svg width={size} height={size} viewBox="0 0 10 10">
{isHold ? (
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
<button
type="button"
onClick={() => onToggle(isExpanded ? null : kf.percentage)}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left"
aria-expanded={isExpanded}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left active:scale-[0.99]"
>
<span className="text-[10px] font-medium text-neutral-400">{label}</span>
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
height="8"
viewBox="0 0 10 10"
fill="currentColor"
className={`text-neutral-500 transition-transform ${isExpanded ? "" : "-rotate-90"}`}
className={`text-neutral-500 transition-transform duration-150 ${isExpanded ? "" : "-rotate-90"}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
type="button"
disabled={!prevKf}
onClick={() => prevKf && onSeek(prevKf.percentage)}
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
title="Previous keyframe"
aria-label={`Previous ${property} keyframe`}
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
>
<ArrowLeft disabled={!prevKf} />
</button>
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
type="button"
disabled={!nextKf}
onClick={() => nextKf && onSeek(nextKf.percentage)}
className="flex h-5 w-3 items-center justify-center disabled:cursor-default"
title="Next keyframe"
aria-label={`Next ${property} keyframe`}
className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']"
>
<ArrowRight disabled={!nextKf} />
</button>
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
: selected
? "bg-panel-accent/14 text-panel-accent"
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
} ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
} ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`}
style={{ paddingLeft: 8 + layer.depth * 16 }}
title={
draggable
? layer.element.hasAttribute("data-hf-group")
? "Double-click to enter group"
: undefined
: "This layer can't be reordered"
}
>
{hasChildren ? (
<button
type="button"
onClick={(e) => toggleCollapse(layer.key, e)}
className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300"
aria-expanded={!isCollapsed}
aria-label={isCollapsed ? "Expand children" : "Collapse children"}
className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-neutral-500 hover:text-neutral-300 before:absolute before:-inset-1.5 before:content-['']"
>
<svg
width="8"
@@ -55,6 +55,7 @@ export function MotionPathNode(props: {
onPointerDown={props.onPointerDown}
onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerUp}
onContextMenu={props.onContextMenu}
/>
)}
@@ -536,12 +536,11 @@ describe("PropertyPanel — flat Layout/Motion timing agreement (whole-plan cohe
if (!xRow) throw new Error("expected an X row");
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
// The diamond button always carries a `title`; the two plain arrow
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev
// arrow is disabled (no earlier keyframe) and the next arrow seeks to
// the 50% keyframe — exactly the case the coherence bug affected.
// At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
// disabled (no earlier keyframe) and the next arrow seeks to the 50%
// keyframe — exactly the case the coherence bug affected.
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find(
(b) => !b.title && !b.disabled,
(b) => b.title === "Next keyframe" && !b.disabled,
);
if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true })));
@@ -582,7 +581,9 @@ describe("PropertyPanel — flat Layout currentPct basis (currentPct follow-up f
if (!xRow) throw new Error("expected an X row");
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
if (!gutter) throw new Error("expected a keyframe gutter on the X row");
const diamond = gutter.querySelector<HTMLButtonElement>("button[title]");
// The diamond is the only gutter button that reports a pressed state;
// the prev/next arrows carry titles too, so `button[title]` is ambiguous.
const diamond = gutter.querySelector<HTMLButtonElement>("button[aria-pressed]");
if (!diamond) throw new Error("expected a keyframe diamond button");
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const handleCopyElementInfo = () => {
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
void navigator.clipboard.writeText(text);
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
setClipboardCopied(true);
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
// Claim the copy only once the write actually lands — a denied clipboard
// permission otherwise reports a copy that never happened.
navigator.clipboard
.writeText(text)
.then(() => {
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info");
setClipboardCopied(true);
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
})
.catch(() => {
showToast("Couldn't copy to the clipboard — check browser permissions", "error");
});
};
if (STUDIO_FLAT_INSPECTOR_ENABLED) {
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
{motionPathCreateAvailable && (
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
motionPathArmed
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -118,7 +118,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
)}
<button
type="button"
className={`rounded-md p-1.5 transition-colors ${
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
prefs.snapEnabled
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -134,7 +134,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
<button
ref={gridButtonRef}
type="button"
className={`rounded-md p-1.5 transition-colors ${
className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
prefs.gridVisible
? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
@@ -144,11 +144,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
e.preventDefault();
setGridPopoverOpen((v) => !v);
}}
title={prefs.gridVisible ? "Grid visible (G)" : "Grid hidden (G)"}
title={
prefs.gridVisible
? "Grid visible (G) — right-click for spacing options"
: "Grid hidden (G) — right-click for spacing options"
}
aria-label="Toggle grid"
>
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
</button>
<button
type="button"
className="absolute -right-0.5 -bottom-0.5 rounded p-0.5 text-white/50 hover:text-white/90 bg-black/50"
onClick={() => setGridPopoverOpen((v) => !v)}
title="Grid options"
aria-label="Grid options"
aria-expanded={gridPopoverOpen}
>
<svg width="7" height="7" viewBox="0 0 8 8" fill="currentColor" aria-hidden="true">
<path d="M1 2.5l3 3 3-3z" />
</svg>
</button>
{gridPopoverOpen && (
<div
@@ -163,6 +163,25 @@ export function Transform3DCube({
setDraft(null);
};
const onKeyDown = (e: React.KeyboardEvent<SVGSVGElement>) => {
const STEP = e.altKey ? 1 : 5;
let next: CubePose | null = null;
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const dir = e.key === "ArrowUp" ? -1 : 1;
next = e.shiftKey
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
: { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) };
} else if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
const dir = e.key === "ArrowRight" ? 1 : -1;
next = e.shiftKey
? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) }
: { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) };
}
if (!next) return;
e.preventDefault();
onPoseCommit(next);
};
return (
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
<svg
@@ -174,8 +193,10 @@ export function Transform3DCube({
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
tabIndex={0}
role="slider"
aria-label="Drag to rotate in 3D; hold Shift to roll; scroll to change depth"
aria-label="3D rotation. Arrow keys rotate X/Y, Shift+arrows roll Z, Alt for fine steps; drag to rotate, scroll to change depth"
aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
shown.rotationY,
)}°, Z ${Math.round(shown.rotationZ)}°`}
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400"
aria-expanded={!collapsed}
className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]"
>
<span>3D Transform</span>
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden>
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />}
<svg
width="9"
height="9"
viewBox="0 0 10 10"
fill="currentColor"
aria-hidden
className={`transition-transform duration-150 ${collapsed ? "-rotate-90" : ""}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
</button>
{collapsed ? null : (
@@ -268,6 +268,10 @@ export function ColorField({
useEffect(() => {
if (!open) return;
// Move focus into the picker on open and restore it on close so Escape
// and keyboard editing work without a pointer round-trip.
panelRef.current?.focus();
const restoreTarget = buttonRef.current;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (!target) return;
@@ -279,6 +283,7 @@ export function ColorField({
if (event.key === "Escape") {
cancelColorGesture();
setOpen(false);
restoreTarget?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown);
@@ -320,7 +325,10 @@ export function ColorField({
? createPortal(
<div
ref={panelRef}
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50"
role="dialog"
aria-label={`${label} color picker`}
tabIndex={-1}
className="fixed z-[9999] w-[292px] overflow-hidden rounded-2xl border border-neutral-700 bg-neutral-950 shadow-2xl shadow-black/50 outline-none"
style={{
left: panelPosition?.left ?? -9999,
top: panelPosition?.top ?? -9999,
@@ -263,6 +263,8 @@ export function ColorGradingControls({
const lutInputRef = useRef<HTMLInputElement>(null);
const [lutOpen, setLutOpen] = useState(false);
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
const [lutImporting, setLutImporting] = useState(false);
const [lutImportError, setLutImportError] = useState<string | null>(null);
const lutAssets = useMemo(
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
[assets],
@@ -282,6 +284,9 @@ export function ColorGradingControls({
const actions = createColorGradingActions(grading, onCommitColorGrading);
const applyPreset = (preset: string) => {
// Pass the LUT through normalize; the preset's own adjust values are its
// look (main's normalize already avoids carrying over grading.adjust,
// which was the bug this PR originally fixed).
const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
if (next) {
track("select", "Preset");
@@ -391,16 +396,21 @@ export function ColorGradingControls({
</select>
<button
type="button"
disabled={!onImportAssets}
disabled={!onImportAssets || lutImporting}
onClick={(event) => {
event.stopPropagation();
lutInputRef.current?.click();
}}
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 disabled:cursor-not-allowed disabled:opacity-40"
title="Import .cube LUT"
className="flex h-8 w-8 items-center justify-center rounded-md bg-panel-input text-panel-text-4 transition-colors hover:bg-panel-hover hover:text-panel-text-1 active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40"
title={lutImporting ? "Importing…" : "Import .cube LUT"}
aria-label="Import .cube LUT"
aria-busy={lutImporting}
>
<Plus size={13} />
{lutImporting ? (
<span className="h-3 w-3 animate-spin rounded-full border border-panel-text-4 border-t-transparent motion-reduce:animate-none" />
) : (
<Plus size={13} />
)}
</button>
<input
ref={lutInputRef}
@@ -409,13 +419,24 @@ export function ColorGradingControls({
multiple
className="hidden"
onChange={(event) => {
void actions.importLut(event.currentTarget.files, onImportAssets, () =>
track("button", "Import LUT"),
);
const files = event.currentTarget.files;
setLutImporting(true);
setLutImportError(null);
void actions
.importLut(files, onImportAssets, () => track("button", "Import LUT"))
.catch(() =>
setLutImportError("LUT import failed — check the .cube file and try again."),
)
.finally(() => setLutImporting(false));
event.currentTarget.value = "";
}}
/>
</div>
{lutImportError && (
<div className="text-[10px] text-red-400" role="alert">
{lutImportError}
</div>
)}
{grading.lut && (
<div className="grid gap-2">
{selectedProjectLut && (
@@ -134,7 +134,15 @@ export function CommitField({
return;
}
if (event.key === "Escape") {
cancelGestureFromKeyEvent(event);
if (cancelGestureFromKeyEvent(event)) return;
// No gesture to cancel means the draft was typed. Escape abandons it
// rather than leaving it for the blur-commit to write.
event.preventDefault();
event.stopPropagation();
dirtyRef.current = false;
setDraft(valueRef.current);
if (liveCommit) onPreview?.(valueRef.current);
event.currentTarget.blur();
return;
}
if (event.key === "Enter") {
@@ -91,6 +91,7 @@ export function ImageFillField({
const track = useTrackDesignInput();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
const selectedAsset = useMemo(
() => resolveSelectedAsset(value, sourceFile, imageAssets),
@@ -101,6 +102,7 @@ export function ImageFillField({
const handleUpload = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return;
setUploading(true);
setUploadError(null);
try {
const uploaded = await onImportAssets(files);
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
@@ -108,6 +110,8 @@ export function ImageFillField({
track("button", "Upload image");
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
}
} catch {
setUploadError("Upload failed — check the file and try again.");
} finally {
setUploading(false);
}
@@ -144,6 +148,11 @@ export function ImageFillField({
}}
/>
</div>
{uploadError && (
<div className="text-[10px] text-red-400" role="alert">
{uploadError}
</div>
)}
{imageAssets.length > 0 ? (
<div className="space-y-3">
{selectedAsset && (
@@ -262,11 +271,49 @@ export function GradientField({
{parsed.stops.map((stop, index) => (
<div
key={`stop-preview-${index}`}
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={`Stop ${index + 1} position`}
aria-valuenow={Math.round(stop.position)}
aria-valuemin={0}
aria-valuemax={100}
onKeyDown={(event) => {
if (disabled) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const step = event.shiftKey ? 10 : 1;
const delta = event.key === "ArrowRight" ? step : -step;
updateStop(index, {
position: Math.max(0, Math.min(100, Math.round(stop.position + delta))),
});
}}
className="absolute top-1/2 h-4 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-studio-accent"
style={{
left: `calc(${stop.position}% - 8px)`,
backgroundColor: stop.color,
}}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => {
if (disabled) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
if (disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return;
const rect = previewRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0) return;
const next = Math.max(
0,
Math.min(100, ((event.clientX - rect.left) / rect.width) * 100),
);
updateStop(index, { position: Math.round(next * 10) / 10 });
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
}}
onPointerCancel={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId);
}}
/>
))}
</div>
@@ -392,7 +439,8 @@ export function GradientField({
type="button"
disabled={disabled || parsed.stops.length >= 6}
onClick={() => addStop()}
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
title={parsed.stops.length >= 6 ? "Maximum 6 stops" : "Add a gradient stop"}
className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:text-neutral-600"
>
<Plus size={12} />
Add stop
@@ -143,6 +143,7 @@ export function FontFamilyField({
const fontInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const [localFonts, setLocalFonts] = useState<string[]>([]);
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
@@ -259,6 +260,8 @@ export function FontFamilyField({
} else {
setFontNotice("No supported font files were imported.");
}
} catch {
setFontNotice("Font import failed — the files were not added. Try again.");
} finally {
setImportingFonts(false);
}
@@ -358,11 +361,17 @@ export function FontFamilyField({
commitFontFamily(buildFontFamilyValue(imported.family));
setQuery("");
setOpen(false);
return;
} else {
// Committing an un-imported family would render a silent fallback,
// so surface the failure and keep the current font instead.
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
}
} catch {
setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`);
} finally {
setImportingFonts(false);
}
return;
}
if (option.source === "Google") loadGoogleFontStylesheet(option.family);
const imported = importedFonts.find(
@@ -383,17 +392,39 @@ export function FontFamilyField({
value={query}
disabled={disabled}
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
onChange={(e) => setQuery(e.target.value)}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
return;
}
if (e.key === "Enter" && filteredOptions[0]) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
commitFamily(filteredOptions[0]);
if (filteredOptions.length === 0) return;
const delta = e.key === "ArrowDown" ? 1 : -1;
const next =
activeIndex < 0 && delta === 1
? 0
: (activeIndex + delta + filteredOptions.length) % filteredOptions.length;
setActiveIndex(next);
document
.querySelector(`[data-font-option-index="${next}"]`)
?.scrollIntoView({ block: "nearest" });
return;
}
const target = filteredOptions[activeIndex] ?? filteredOptions[0];
if (e.key === "Enter" && target) {
e.preventDefault();
commitFamily(target);
}
}}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600"
/>
{canQueryLocalFonts && (
@@ -437,19 +468,24 @@ export function FontFamilyField({
{filteredOptions.length === 0 ? (
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
) : (
filteredOptions.map((option) => (
filteredOptions.map((option, index) => (
<button
key={`${option.source}-${option.family}`}
type="button"
data-font-option-index={index}
onClick={() => commitFamily(option)}
className={`flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-2 py-2 text-left text-[11px] transition-colors ${
option.family === currentFamily
? "bg-studio-accent/15 text-neutral-50"
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
index === activeIndex
? "bg-neutral-800 text-neutral-50"
: option.family === currentFamily
? "bg-studio-accent/15 text-neutral-50"
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100"
}`}
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-medium">{option.family}</span>
<span className="truncate font-medium" style={{ fontFamily: `"${option.family}"` }}>
{option.family}
</span>
{renderAliasFor(option.family) && (
<span className="flex-shrink-0 text-[9px] text-neutral-500">
{renderAliasFor(option.family)}
@@ -74,6 +74,8 @@ export function MetricField({
onPointerDown: handleScrubPointerDown,
onPointerMove: handleScrubPointerMove,
onPointerUp: handleScrubPointerUp,
onPointerCancel: handleScrubPointerUp,
onLostPointerCapture: handleScrubPointerUp,
} as const)
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
@@ -180,6 +182,7 @@ export function SliderControl({
step={step}
value={draft}
disabled={disabled}
aria-label={trackName}
onChange={(e) => {
const n = Number(e.target.value);
setDraft(n);
@@ -228,6 +231,7 @@ export function SegmentedControl({
if (option.value !== value) track("segmented", trackName);
onChange(option.value);
}}
aria-pressed={option.value === value}
className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
option.value === value
? "bg-panel-hover text-white"
@@ -292,23 +296,15 @@ export function Section({
defaultCollapsed?: boolean;
}) {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
const collapseIcon = collapsed ? (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
className="flex-shrink-0 text-panel-text-5"
>
<path d="M6 2.5v7M2.5 6h7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
) : (
const collapseIcon = (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="currentColor"
className="flex-shrink-0 text-panel-text-5"
className={`flex-shrink-0 text-panel-text-5 transition-transform duration-150 ${
collapsed ? "-rotate-90" : ""
}`}
>
<path d="M2 3l3 4 3-4z" />
</svg>
@@ -322,6 +318,7 @@ export function Section({
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left"
>
<h3 className="text-[12px] font-semibold text-panel-text-1">{title}</h3>
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
@@ -116,21 +116,40 @@ export function StyleSections({
: "Solid";
const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage);
// Remember the last authored gradient so an exploratory Solid↔Gradient
// toggle restores it instead of destroying it with the default gradient.
const lastGradientRef = useRef<string | null>(
backgroundImage.includes("gradient") ? backgroundImage : null,
);
// The remembered gradient is per-element: reset on selection change so
// element A's gradient can't leak onto element B via the fill-mode toggle.
// (The effect below re-stores it when the new element has its own gradient.)
useEffect(() => {
lastGradientRef.current = null;
}, [element.id, element.selector]);
useEffect(() => {
setPreferredFillMode(fillMode);
if (backgroundImage.includes("gradient")) {
lastGradientRef.current = backgroundImage;
}
}, [fillMode, element.id, element.selector, backgroundImage]);
const handleFillModeChange = (nextMode: string) => {
setPreferredFillMode(nextMode);
if (nextMode === "Solid") {
if (backgroundImage.includes("gradient")) {
lastGradientRef.current = backgroundImage;
}
onSetStyle("background-image", "none");
return;
}
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
onSetStyle(
"background-image",
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
lastGradientRef.current ??
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
);
}
};
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
await commitAnimatedTransformValue(
axis,
parsed,
"Cannot edit position — animation callbacks not available",
"This element's position can't be edited here yet — it is driven by its animation",
)
)
return;
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
return;
}
if (hasGsapAnimation) {
showToast?.("Cannot edit size — animation callbacks not available");
showToast?.("This element's size can't be edited here yet — it is driven by its animation");
return;
}
const current = readStudioBoxSize(element.element);
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
await commitAnimatedTransformValue(
"rotation",
parsed,
"Cannot edit rotation — animation callbacks not available",
"This element's rotation can't be edited here yet — it is driven by its animation",
)
)
return;
+12
View File
@@ -186,3 +186,15 @@ export function trackStudioFeedback(
source: "studio",
});
}
export function trackBlockParamCommit(props: {
tone: "saved" | "error" | "confirm";
blockName: string;
key: string;
}): void {
trackEvent("studio_block_param_commit", {
tone: props.tone,
block_name: props.blockName,
param_key: props.key,
});
}