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. // require intrusive middleware changes beyond this PR's scope.
"minLines": 6, "minLines": 6,
"ignore": [ "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 // AWS Lambda and GCP Cloud Run deliberately mirror the same distributed
// rendering lifecycle while retaining provider-specific SDK, storage, and // rendering lifecycle while retaining provider-specific SDK, storage, and
// retry semantics. The Plan v2 AWS adapter extends that existing symmetry; // 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/BlockParamsPanel.tsx",
"packages/studio/src/components/editor/DomEditOverlay.tsx", "packages/studio/src/components/editor/DomEditOverlay.tsx",
"packages/studio/src/components/editor/FileTree.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/FileTreeNodes.tsx",
"packages/studio/src/components/editor/LayersPanel.tsx", "packages/studio/src/components/editor/LayersPanel.tsx",
"packages/studio/src/components/editor/MotionPathNode.tsx", "packages/studio/src/components/editor/MotionPathNode.tsx",
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
<button <button
type="button" type="button"
onClick={() => setExpanded((v) => !v)} 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 <span
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent" 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 <button
type="button" type="button"
onClick={onClick} 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} title={title}
aria-label={title}
> >
<svg <svg
width="12" width="12"
@@ -87,13 +88,15 @@ export function PropertyRow({
</span> </span>
<button <button
type="button" type="button"
role="switch"
aria-checked={isVisible}
onClick={() => onCommit(isVisible ? "hidden" : "visible")} 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 }} style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"} title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
> >
<span <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={{ style={{
width: 12, width: 12,
height: 12, height: 12,
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Arc Motion</span> <span className={LABEL}>Arc Motion</span>
<button <button
type="button" type="button"
role="switch"
aria-checked={Boolean(arcPath.enabled)}
aria-label="Arc motion"
onClick={handleToggle} onClick={handleToggle}
disabled={disabled} 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 }} style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"} title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
> >
<span <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={{ style={{
width: 12, width: 12,
height: 12, height: 12,
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
<span className={LABEL}>Auto-Rotate</span> <span className={LABEL}>Auto-Rotate</span>
<button <button
type="button" type="button"
role="switch"
aria-checked={Boolean(arcPath.autoRotate)}
aria-label="Auto-rotate along path"
onClick={handleAutoRotate} onClick={handleAutoRotate}
disabled={disabled} disabled={disabled}
className="relative rounded-full transition-all duration-150" className="relative rounded-full transition-colors duration-200"
style={{ style={{
width: 28, width: 28,
height: 16, height: 16,
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
} }
> >
<span <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={{ style={{
width: 12, width: 12,
height: 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 type { BlockParam } from "@hyperframes/core/registry";
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
import { trackBlockParamCommit } from "../../telemetry/events";
interface BlockParamsPanelProps { interface BlockParamsPanelProps {
blockName: string; blockName: string;
@@ -9,12 +12,17 @@ interface BlockParamsPanelProps {
onClose: () => void; onClose: () => void;
} }
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
export const BlockParamsPanel = memo(function BlockParamsPanel({ export const BlockParamsPanel = memo(function BlockParamsPanel({
blockName,
blockTitle, blockTitle,
params, params,
compositionPath: _compositionPath, compositionPath,
onClose, onClose,
}: BlockParamsPanelProps) { }: BlockParamsPanelProps) {
const fileManager = useFileManagerContextOptional();
const { setRefreshKey } = useStudioPlaybackContext();
const [values, setValues] = useState<Record<string, string>>(() => { const [values, setValues] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
for (const p of params) { for (const p of params) {
@@ -22,10 +30,107 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
} }
return initial; 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) => { const commitParamNow = useCallback(
setValues((prev) => ({ ...prev, [key]: value })); 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -34,7 +139,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
<button <button
type="button" type="button"
onClick={onClose} 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 <svg
width="14" width="14"
@@ -56,14 +162,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider"> <div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
Parameters Parameters
</div> </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) => ( {params.map((param) => (
<ParamControl <ParamControl
key={param.key} key={param.key}
param={param} param={param}
value={values[param.key] ?? param.default} value={values[param.key] ?? param.default}
disabled={!fileManager}
onChange={(v) => handleChange(param.key, v)} 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>
</div> </div>
); );
@@ -72,10 +202,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
function ParamControl({ function ParamControl({
param, param,
value, value,
disabled,
onChange, onChange,
}: { }: {
param: BlockParam; param: BlockParam;
value: string; value: string;
disabled?: boolean;
onChange: (value: string) => void; onChange: (value: string) => void;
}) { }) {
return ( return (
@@ -87,14 +219,18 @@ function ParamControl({
<input <input
type="color" type="color"
value={value} value={value}
disabled={disabled}
aria-label={`${param.label} color`}
onChange={(e) => onChange(e.target.value)} 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 <input
type="text" type="text"
value={value} value={value}
disabled={disabled}
aria-label={`${param.label} value`}
onChange={(e) => onChange(e.target.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> </div>
)} )}
@@ -107,8 +243,10 @@ function ParamControl({
max={param.max ?? 100} max={param.max ?? 100}
step={param.step ?? 1} step={param.step ?? 1}
value={value} value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)} 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> <span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
</div> </div>
@@ -118,16 +256,20 @@ function ParamControl({
<input <input
type="text" type="text"
value={value} value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)} 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 && ( {param.type === "select" && param.options && (
<select <select
value={value} value={value}
disabled={disabled}
aria-label={param.label}
onChange={(e) => onChange(e.target.value)} 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) => ( {param.options.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
stroke="rgba(255,255,255,0.24)" stroke="rgba(255,255,255,0.24)"
strokeWidth={1.5} strokeWidth={1.5}
/> />
<circle <circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
cx={sTL} <circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
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 <circle
cx={PREVIEW_W - sBR} cx={PREVIEW_W - sBR}
cy={PREVIEW_H - sBR} cy={PREVIEW_H - sBR}
r={3} r={3}
fill={linked ? "#3b82f6" : "#a78bfa"} 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> </svg>
<button <button
@@ -49,7 +49,12 @@ export const FileTree = memo(function FileTree({
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [inlineInput, setInlineInput] = useState<InlineInputState | 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 [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
const dragSourceRef = useRef<string | null>(null); const dragSourceRef = useRef<string | null>(null);
@@ -145,13 +150,23 @@ export const FileTree = memo(function FileTree({
// ── Delete ── // ── Delete ──
const handleDelete = useCallback((path: string) => { const handleDelete = useCallback(
setDeleteTarget(path); (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(() => { const handleDeleteConfirm = useCallback(() => {
if (deleteTarget) { if (deleteTarget) {
onDeleteFile?.(deleteTarget); onDeleteFile?.(deleteTarget.path);
setDeleteTarget(null); setDeleteTarget(null);
} }
}, [deleteTarget, onDeleteFile]); }, [deleteTarget, onDeleteFile]);
@@ -227,15 +242,17 @@ export const FileTree = memo(function FileTree({
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<button <button
onClick={() => handleNewFile("")} 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" title="New File"
aria-label="New File"
> >
<Plus size={12} weight="bold" /> <Plus size={12} weight="bold" />
</button> </button>
<button <button
onClick={() => handleNewFolder("")} 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" title="New Folder"
aria-label="New Folder"
> >
<FolderSimplePlus size={12} weight="duotone" /> <FolderSimplePlus size={12} weight="duotone" />
</button> </button>
@@ -274,6 +291,11 @@ export const FileTree = memo(function FileTree({
onCancel={() => inlineInput.onCancel?.()} 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) => {children.map((child) =>
child.isFile && child.children.size === 0 ? ( child.isFile && child.children.size === 0 ? (
<TreeFile <TreeFile
@@ -308,15 +330,22 @@ export const FileTree = memo(function FileTree({
)} )}
</div> </div>
{/* Delete confirmation overlay */} {/* Delete confirmation — anchored near the row it was invoked on */}
{deleteTarget && ( {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 <DeleteConfirm
name={ name={
deleteTarget.includes("/") deleteTarget.path.includes("/")
? deleteTarget.slice(deleteTarget.lastIndexOf("/") + 1) ? deleteTarget.path.slice(deleteTarget.path.lastIndexOf("/") + 1)
: deleteTarget : deleteTarget.path
} }
isFolder={deleteTarget.isFolder}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
onCancel={handleDeleteCancel} onCancel={handleDeleteCancel}
/> />
@@ -43,9 +43,14 @@ export function ContextMenu({
onDelete: (path: string) => void; onDelete: (path: string) => void;
}) { }) {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
useEffect(() => { 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) => { const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) { if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose(); onClose();
@@ -59,9 +64,28 @@ export function ContextMenu({
return () => { return () => {
document.removeEventListener("mousedown", handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape); document.removeEventListener("keydown", handleEscape);
restoreFocusRef.current?.focus();
}; };
}, [onClose]); }, [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 adjustedX = Math.min(state.x, window.innerWidth - 180);
const adjustedY = Math.min(state.y, window.innerHeight - 200); const adjustedY = Math.min(state.y, window.innerHeight - 200);
@@ -74,13 +98,16 @@ export function ContextMenu({
return ( return (
<div <div
ref={menuRef} 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]" 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 }} style={{ left: adjustedX, top: adjustedY }}
> >
{state.targetIsFolder && ( {state.targetIsFolder && (
<> <>
<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={() => { onClick={() => {
onNewFile(state.targetPath); onNewFile(state.targetPath);
onClose(); onClose();
@@ -90,7 +117,8 @@ export function ContextMenu({
New File New File
</button> </button>
<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={() => { onClick={() => {
onNewFolder(state.targetPath); onNewFolder(state.targetPath);
onClose(); onClose();
@@ -105,7 +133,8 @@ export function ContextMenu({
{!state.targetIsFolder && ( {!state.targetIsFolder && (
<> <>
<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={() => { onClick={() => {
onNewFile(parentPath); onNewFile(parentPath);
onClose(); onClose();
@@ -118,7 +147,8 @@ export function ContextMenu({
</> </>
)} )}
<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={() => { onClick={() => {
onRename(state.targetPath); onRename(state.targetPath);
onClose(); onClose();
@@ -129,7 +159,8 @@ export function ContextMenu({
</button> </button>
{!state.targetIsFolder && ( {!state.targetIsFolder && (
<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={() => { onClick={() => {
onDuplicate(state.targetPath); onDuplicate(state.targetPath);
onClose(); onClose();
@@ -141,7 +172,8 @@ export function ContextMenu({
)} )}
<div className="border-t border-neutral-700 my-1" /> <div className="border-t border-neutral-700 my-1" />
<button <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={() => { onClick={() => {
onDelete(state.targetPath); onDelete(state.targetPath);
onClose(); onClose();
@@ -172,6 +204,13 @@ export function InlineInput({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const committedRef = useRef(false); const committedRef = useRef(false);
const [value, setValue] = useState(defaultValue); 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 // eslint-disable-next-line no-restricted-syntax
useEffect(() => { useEffect(() => {
@@ -196,8 +235,16 @@ export function InlineInput({
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) commit(trimmed); if (!trimmed) {
else onCancel(); onCancel();
return;
}
const invalid = validate(trimmed);
if (invalid) {
setError(invalid);
return;
}
commit(trimmed);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onCancel(); onCancel();
@@ -206,8 +253,7 @@ export function InlineInput({
const handleBlur = () => { const handleBlur = () => {
const trimmed = value.trim(); const trimmed = value.trim();
if (trimmed && trimmed !== defaultValue && !(/[/\\]/.test(trimmed) || trimmed.includes(".."))) if (trimmed && trimmed !== defaultValue && !validate(trimmed)) commit(trimmed);
commit(trimmed);
else onCancel(); else onCancel();
}; };
@@ -221,15 +267,28 @@ export function InlineInput({
) : ( ) : (
<FileIcon path={value} /> <FileIcon path={value} />
)} )}
<input <div className="flex-1 min-w-0">
ref={inputRef} <input
value={value} ref={inputRef}
onChange={(e) => setValue(e.target.value)} value={value}
onKeyDown={handleKeyDown} onChange={(e) => {
onBlur={handleBlur} setValue(e.target.value);
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]" if (error) setError(null);
spellCheck={false} }}
/> 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> </div>
); );
} }
@@ -238,10 +297,12 @@ export function InlineInput({
export function DeleteConfirm({ export function DeleteConfirm({
name, name,
isFolder,
onConfirm, onConfirm,
onCancel, onCancel,
}: { }: {
name: string; name: string;
isFolder?: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel: () => 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" 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"> <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> </p>
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<button <button
@@ -61,6 +61,11 @@ export function GsapAnimationList({
return ( return (
<div className="space-y-2"> <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) => ( {animations.map((animation, index) => (
<AnimationCard <AnimationCard
{...trackedCallbacks} {...trackedCallbacks}
@@ -29,9 +29,11 @@ export const KeyframeDiamond = memo(function KeyframeDiamond({
e.stopPropagation(); e.stopPropagation();
onClick(); 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 }} style={{ color, opacity }}
title={title} title={title}
aria-label={title}
aria-pressed={state === "active"}
> >
<svg width={size} height={size} viewBox="0 0 10 10"> <svg width={size} height={size} viewBox="0 0 10 10">
{isHold ? ( {isHold ? (
@@ -104,7 +104,8 @@ export function KeyframeEaseList({
<button <button
type="button" type="button"
onClick={() => onToggle(isExpanded ? null : kf.percentage)} 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="text-[10px] font-medium text-neutral-400">{label}</span>
<span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span> <span className="ml-auto text-[9px] text-neutral-500">{easeLabel}</span>
@@ -113,7 +114,7 @@ export function KeyframeEaseList({
height="8" height="8"
viewBox="0 0 10 10" viewBox="0 0 10 10"
fill="currentColor" 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" /> <path d="M2 3l3 4 3-4z" />
</svg> </svg>
@@ -177,7 +177,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
type="button" type="button"
disabled={!prevKf} disabled={!prevKf}
onClick={() => prevKf && onSeek(prevKf.percentage)} 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} /> <ArrowLeft disabled={!prevKf} />
</button> </button>
@@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({
type="button" type="button"
disabled={!nextKf} disabled={!nextKf}
onClick={() => nextKf && onSeek(nextKf.percentage)} 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} /> <ArrowRight disabled={!nextKf} />
</button> </button>
@@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() {
: selected : selected
? "bg-panel-accent/14 text-panel-accent" ? "bg-panel-accent/14 text-panel-accent"
: "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1" : "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 }} 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 ? ( {hasChildren ? (
<button <button
type="button" type="button"
onClick={(e) => toggleCollapse(layer.key, e)} 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 <svg
width="8" width="8"
@@ -55,6 +55,7 @@ export function MotionPathNode(props: {
onPointerDown={props.onPointerDown} onPointerDown={props.onPointerDown}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerUp}
onContextMenu={props.onContextMenu} 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"); if (!xRow) throw new Error("expected an X row");
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
if (!gutter) throw new Error("expected a keyframe gutter on the X row"); if (!gutter) throw new Error("expected a keyframe gutter on the X row");
// The diamond button always carries a `title`; the two plain arrow // At currentPct=0 (playhead on the 0% keyframe) the prev arrow is
// buttons don't. At currentPct=0 (playhead on the 0% keyframe), the prev // disabled (no earlier keyframe) and the next arrow seeks to the 50%
// arrow is disabled (no earlier keyframe) and the next arrow seeks to // keyframe — exactly the case the coherence bug affected.
// the 50% keyframe — exactly the case the coherence bug affected.
const nextArrow = Array.from(gutter.querySelectorAll<HTMLButtonElement>("button")).find( 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"); if (!nextArrow) throw new Error("expected an enabled next-keyframe arrow button");
act(() => nextArrow.dispatchEvent(new MouseEvent("click", { bubbles: true }))); 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"); if (!xRow) throw new Error("expected an X row");
const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]'); const gutter = xRow.querySelector('[data-flat-kf-gutter="true"]');
if (!gutter) throw new Error("expected a keyframe gutter on the X row"); 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"); if (!diamond) throw new Error("expected a keyframe diamond button");
// KeyframeDiamond's title mapping: active -> "Remove ... keyframe", // KeyframeDiamond's title mapping: active -> "Remove ... keyframe",
// inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes". // inactive -> "Add ... keyframe", ghost -> "Convert ... to keyframes".
@@ -234,11 +234,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const handleCopyElementInfo = () => { const handleCopyElementInfo = () => {
const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef); const text = buildElementInfoText(element, sourceLabel, gsapAnimations, previewIframeRef);
void navigator.clipboard.writeText(text); // Claim the copy only once the write actually lands — a denied clipboard
showToast(`Copied element info for ${element.label} — paste into any AI agent`, "info"); // permission otherwise reports a copy that never happened.
setClipboardCopied(true); navigator.clipboard
clearTimeout(clipboardTimerRef.current); .writeText(text)
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500); .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) { if (STUDIO_FLAT_INSPECTOR_ENABLED) {
@@ -102,7 +102,7 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
{motionPathCreateAvailable && ( {motionPathCreateAvailable && (
<button <button
type="button" type="button"
className={`rounded-md p-1.5 transition-colors ${ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
motionPathArmed motionPathArmed
? "bg-studio-accent/20 text-studio-accent" ? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80" : "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 <button
type="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 prefs.snapEnabled
? "bg-studio-accent/20 text-studio-accent" ? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80" : "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 <button
ref={gridButtonRef} ref={gridButtonRef}
type="button" type="button"
className={`rounded-md p-1.5 transition-colors ${ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
prefs.gridVisible prefs.gridVisible
? "bg-studio-accent/20 text-studio-accent" ? "bg-studio-accent/20 text-studio-accent"
: "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80" : "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(); e.preventDefault();
setGridPopoverOpen((v) => !v); 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" aria-label="Toggle grid"
> >
<GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} /> <GridFour size={16} weight={prefs.gridVisible ? "fill" : "regular"} />
</button> </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 && ( {gridPopoverOpen && (
<div <div
@@ -163,6 +163,25 @@ export function Transform3DCube({
setDraft(null); 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 ( return (
<div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950"> <div className="relative overflow-hidden rounded-lg border border-neutral-800 bg-gradient-to-b from-neutral-900 to-neutral-950">
<svg <svg
@@ -174,8 +193,10 @@ export function Transform3DCube({
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp} onPointerUp={onPointerUp}
onPointerCancel={onPointerUp} onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
tabIndex={0}
role="slider" 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( aria-valuetext={`X ${Math.round(shown.rotationX)}°, Y ${Math.round(
shown.rotationY, shown.rotationY,
)}°, Z ${Math.round(shown.rotationZ)}°`} )}°, Z ${Math.round(shown.rotationZ)}°`}
@@ -322,11 +322,19 @@ export function PropertyPanel3dTransform({
<button <button
type="button" type="button"
onClick={() => setCollapsed((v) => !v)} 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> <span>3D Transform</span>
<svg width="9" height="9" viewBox="0 0 10 10" fill="currentColor" aria-hidden> <svg
{collapsed ? <path d="M3 2l4 3-4 3z" /> : <path d="M2 3l3 4 3-4z" />} 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> </svg>
</button> </button>
{collapsed ? null : ( {collapsed ? null : (
@@ -268,6 +268,10 @@ export function ColorField({
useEffect(() => { useEffect(() => {
if (!open) return; 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 handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null; const target = event.target as Node | null;
if (!target) return; if (!target) return;
@@ -279,6 +283,7 @@ export function ColorField({
if (event.key === "Escape") { if (event.key === "Escape") {
cancelColorGesture(); cancelColorGesture();
setOpen(false); setOpen(false);
restoreTarget?.focus();
} }
}; };
document.addEventListener("pointerdown", handlePointerDown); document.addEventListener("pointerdown", handlePointerDown);
@@ -320,7 +325,10 @@ export function ColorField({
? createPortal( ? createPortal(
<div <div
ref={panelRef} 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={{ style={{
left: panelPosition?.left ?? -9999, left: panelPosition?.left ?? -9999,
top: panelPosition?.top ?? -9999, top: panelPosition?.top ?? -9999,
@@ -263,6 +263,8 @@ export function ColorGradingControls({
const lutInputRef = useRef<HTMLInputElement>(null); const lutInputRef = useRef<HTMLInputElement>(null);
const [lutOpen, setLutOpen] = useState(false); const [lutOpen, setLutOpen] = useState(false);
const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null); const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null);
const [lutImporting, setLutImporting] = useState(false);
const [lutImportError, setLutImportError] = useState<string | null>(null);
const lutAssets = useMemo( const lutAssets = useMemo(
() => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)),
[assets], [assets],
@@ -282,6 +284,9 @@ export function ColorGradingControls({
const actions = createColorGradingActions(grading, onCommitColorGrading); const actions = createColorGradingActions(grading, onCommitColorGrading);
const applyPreset = (preset: string) => { 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 }); const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut });
if (next) { if (next) {
track("select", "Preset"); track("select", "Preset");
@@ -391,16 +396,21 @@ export function ColorGradingControls({
</select> </select>
<button <button
type="button" type="button"
disabled={!onImportAssets} disabled={!onImportAssets || lutImporting}
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
lutInputRef.current?.click(); 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" 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="Import .cube LUT" title={lutImporting ? "Importing…" : "Import .cube LUT"}
aria-label="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> </button>
<input <input
ref={lutInputRef} ref={lutInputRef}
@@ -409,13 +419,24 @@ export function ColorGradingControls({
multiple multiple
className="hidden" className="hidden"
onChange={(event) => { onChange={(event) => {
void actions.importLut(event.currentTarget.files, onImportAssets, () => const files = event.currentTarget.files;
track("button", "Import LUT"), 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 = ""; event.currentTarget.value = "";
}} }}
/> />
</div> </div>
{lutImportError && (
<div className="text-[10px] text-red-400" role="alert">
{lutImportError}
</div>
)}
{grading.lut && ( {grading.lut && (
<div className="grid gap-2"> <div className="grid gap-2">
{selectedProjectLut && ( {selectedProjectLut && (
@@ -134,7 +134,15 @@ export function CommitField({
return; return;
} }
if (event.key === "Escape") { 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; return;
} }
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -91,6 +91,7 @@ export function ImageFillField({
const track = useTrackDesignInput(); const track = useTrackDesignInput();
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]); const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
const selectedAsset = useMemo( const selectedAsset = useMemo(
() => resolveSelectedAsset(value, sourceFile, imageAssets), () => resolveSelectedAsset(value, sourceFile, imageAssets),
@@ -101,6 +102,7 @@ export function ImageFillField({
const handleUpload = async (files: FileList | null) => { const handleUpload = async (files: FileList | null) => {
if (!files?.length || !onImportAssets) return; if (!files?.length || !onImportAssets) return;
setUploading(true); setUploading(true);
setUploadError(null);
try { try {
const uploaded = await onImportAssets(files); const uploaded = await onImportAssets(files);
const nextImage = uploaded.find((a) => IMAGE_EXT.test(a)); const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
@@ -108,6 +110,8 @@ export function ImageFillField({
track("button", "Upload image"); track("button", "Upload image");
onCommit(`url("${toProjectRootAssetPath(nextImage)}")`); onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
} }
} catch {
setUploadError("Upload failed — check the file and try again.");
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -144,6 +148,11 @@ export function ImageFillField({
}} }}
/> />
</div> </div>
{uploadError && (
<div className="text-[10px] text-red-400" role="alert">
{uploadError}
</div>
)}
{imageAssets.length > 0 ? ( {imageAssets.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{selectedAsset && ( {selectedAsset && (
@@ -262,11 +271,49 @@ export function GradientField({
{parsed.stops.map((stop, index) => ( {parsed.stops.map((stop, index) => (
<div <div
key={`stop-preview-${index}`} 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={{ style={{
left: `calc(${stop.position}% - 8px)`, left: `calc(${stop.position}% - 8px)`,
backgroundColor: stop.color, 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> </div>
@@ -392,7 +439,8 @@ export function GradientField({
type="button" type="button"
disabled={disabled || parsed.stops.length >= 6} disabled={disabled || parsed.stops.length >= 6}
onClick={() => addStop()} 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} /> <Plus size={12} />
Add stop Add stop
@@ -143,6 +143,7 @@ export function FontFamilyField({
const fontInputRef = useRef<HTMLInputElement | null>(null); const fontInputRef = useRef<HTMLInputElement | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(-1);
const [localFonts, setLocalFonts] = useState<string[]>([]); const [localFonts, setLocalFonts] = useState<string[]>([]);
const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]); const [localFontData, setLocalFontData] = useState<LocalFontData[]>([]);
const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]); const [googleFonts, setGoogleFonts] = useState<string[]>(() => [...POPULAR_GOOGLE_FONT_FAMILIES]);
@@ -259,6 +260,8 @@ export function FontFamilyField({
} else { } else {
setFontNotice("No supported font files were imported."); setFontNotice("No supported font files were imported.");
} }
} catch {
setFontNotice("Font import failed — the files were not added. Try again.");
} finally { } finally {
setImportingFonts(false); setImportingFonts(false);
} }
@@ -358,11 +361,17 @@ export function FontFamilyField({
commitFontFamily(buildFontFamilyValue(imported.family)); commitFontFamily(buildFontFamilyValue(imported.family));
setQuery(""); setQuery("");
setOpen(false); 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 { } finally {
setImportingFonts(false); setImportingFonts(false);
} }
return;
} }
if (option.source === "Google") loadGoogleFontStylesheet(option.family); if (option.source === "Google") loadGoogleFontStylesheet(option.family);
const imported = importedFonts.find( const imported = importedFonts.find(
@@ -383,17 +392,39 @@ export function FontFamilyField({
value={query} value={query}
disabled={disabled} disabled={disabled}
placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"} placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(-1);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
setOpen(false); setOpen(false);
return;
} }
if (e.key === "Enter" && filteredOptions[0]) { if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault(); 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" 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 && ( {canQueryLocalFonts && (
@@ -437,19 +468,24 @@ export function FontFamilyField({
{filteredOptions.length === 0 ? ( {filteredOptions.length === 0 ? (
<div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div> <div className="px-2 py-3 text-[11px] text-neutral-500">No fonts found.</div>
) : ( ) : (
filteredOptions.map((option) => ( filteredOptions.map((option, index) => (
<button <button
key={`${option.source}-${option.family}`} key={`${option.source}-${option.family}`}
type="button" type="button"
data-font-option-index={index}
onClick={() => commitFamily(option)} 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 ${ 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 index === activeIndex
? "bg-studio-accent/15 text-neutral-50" ? "bg-neutral-800 text-neutral-50"
: "text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100" : 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="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) && ( {renderAliasFor(option.family) && (
<span className="flex-shrink-0 text-[9px] text-neutral-500"> <span className="flex-shrink-0 text-[9px] text-neutral-500">
{renderAliasFor(option.family)} {renderAliasFor(option.family)}
@@ -74,6 +74,8 @@ export function MetricField({
onPointerDown: handleScrubPointerDown, onPointerDown: handleScrubPointerDown,
onPointerMove: handleScrubPointerMove, onPointerMove: handleScrubPointerMove,
onPointerUp: handleScrubPointerUp, onPointerUp: handleScrubPointerUp,
onPointerCancel: handleScrubPointerUp,
onLostPointerCapture: handleScrubPointerUp,
} as const) } as const)
: ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const); : ({ className: "flex-shrink-0 text-[11px] font-medium text-neutral-500" } as const);
@@ -180,6 +182,7 @@ export function SliderControl({
step={step} step={step}
value={draft} value={draft}
disabled={disabled} disabled={disabled}
aria-label={trackName}
onChange={(e) => { onChange={(e) => {
const n = Number(e.target.value); const n = Number(e.target.value);
setDraft(n); setDraft(n);
@@ -228,6 +231,7 @@ export function SegmentedControl({
if (option.value !== value) track("segmented", trackName); if (option.value !== value) track("segmented", trackName);
onChange(option.value); 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 ${ className={`min-w-0 truncate rounded px-2 py-[5px] text-[11px] font-medium transition-colors disabled:cursor-not-allowed ${
option.value === value option.value === value
? "bg-panel-hover text-white" ? "bg-panel-hover text-white"
@@ -292,23 +296,15 @@ export function Section({
defaultCollapsed?: boolean; defaultCollapsed?: boolean;
}) { }) {
const [collapsed, setCollapsed] = useState(defaultCollapsed); const [collapsed, setCollapsed] = useState(defaultCollapsed);
const collapseIcon = collapsed ? ( const collapseIcon = (
<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>
) : (
<svg <svg
width="10" width="10"
height="10" height="10"
viewBox="0 0 10 10" viewBox="0 0 10 10"
fill="currentColor" 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" /> <path d="M2 3l3 4 3-4z" />
</svg> </svg>
@@ -322,6 +318,7 @@ export function Section({
<button <button
type="button" type="button"
onClick={() => setCollapsed((v) => !v)} onClick={() => setCollapsed((v) => !v)}
aria-expanded={!collapsed}
className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left" 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> <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 { Eye, Layers, Palette, Settings, Square, Zap } from "../../icons/SystemIcons";
import { buildDefaultGradientModel, serializeGradient } from "./gradientValue"; import { buildDefaultGradientModel, serializeGradient } from "./gradientValue";
import { isTextEditableSelection, type DomEditSelection } from "./domEditing"; import { isTextEditableSelection, type DomEditSelection } from "./domEditing";
@@ -116,21 +116,40 @@ export function StyleSections({
: "Solid"; : "Solid";
const [preferredFillMode, setPreferredFillMode] = useState(fillMode); const [preferredFillMode, setPreferredFillMode] = useState(fillMode);
const imageUrl = extractBackgroundImageUrl(backgroundImage); 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(() => { useEffect(() => {
setPreferredFillMode(fillMode); setPreferredFillMode(fillMode);
if (backgroundImage.includes("gradient")) {
lastGradientRef.current = backgroundImage;
}
}, [fillMode, element.id, element.selector, backgroundImage]); }, [fillMode, element.id, element.selector, backgroundImage]);
const handleFillModeChange = (nextMode: string) => { const handleFillModeChange = (nextMode: string) => {
setPreferredFillMode(nextMode); setPreferredFillMode(nextMode);
if (nextMode === "Solid") { if (nextMode === "Solid") {
if (backgroundImage.includes("gradient")) {
lastGradientRef.current = backgroundImage;
}
onSetStyle("background-image", "none"); onSetStyle("background-image", "none");
return; return;
} }
if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) { if (nextMode === "Gradient" && !backgroundImage.includes("gradient")) {
onSetStyle( onSetStyle(
"background-image", "background-image",
serializeGradient(buildDefaultGradientModel(styles["background-color"])), lastGradientRef.current ??
serializeGradient(buildDefaultGradientModel(styles["background-color"])),
); );
} }
}; };
@@ -69,7 +69,7 @@ export function createTransformCommitHandlers({
await commitAnimatedTransformValue( await commitAnimatedTransformValue(
axis, axis,
parsed, 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; return;
@@ -91,7 +91,7 @@ export function createTransformCommitHandlers({
return; return;
} }
if (hasGsapAnimation) { 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; return;
} }
const current = readStudioBoxSize(element.element); const current = readStudioBoxSize(element.element);
@@ -118,7 +118,7 @@ export function createTransformCommitHandlers({
await commitAnimatedTransformValue( await commitAnimatedTransformValue(
"rotation", "rotation",
parsed, 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; return;
+12
View File
@@ -186,3 +186,15 @@ export function trackStudioFeedback(
source: "studio", 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,
});
}