Skip to content

Commit 7e3235d

Browse files
committed
fix(studio): editor panel UX — commit safety, keyboard a11y, wired BlockParamsPanel
1 parent 2675a4f commit 7e3235d

27 files changed

Lines changed: 627 additions & 145 deletions

packages/studio/src/components/editor/AnimationCard.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ export const AnimationCard = memo(function AnimationCard({
154154
<button
155155
type="button"
156156
onClick={() => setExpanded((v) => !v)}
157-
className="flex w-full items-center gap-2 py-1.5"
157+
aria-expanded={expanded}
158+
className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
158159
>
159160
<span
160161
className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
@@ -176,7 +177,7 @@ export const AnimationCard = memo(function AnimationCard({
176177
height="10"
177178
viewBox="0 0 10 10"
178179
fill="currentColor"
179-
className={`flex-shrink-0 text-neutral-500 transition-transform ${expanded ? "" : "-rotate-90"}`}
180+
className={`flex-shrink-0 text-neutral-500 transition-transform duration-150 ${expanded ? "" : "-rotate-90"}`}
180181
>
181182
<path d="M2 3l3 4 3-4z" />
182183
</svg>

packages/studio/src/components/editor/AnimationCardParts.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
4646
<button
4747
type="button"
4848
onClick={onClick}
49-
className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
49+
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]"
5050
title={title}
51+
aria-label={title}
5152
>
5253
<svg
5354
width="12"
@@ -87,13 +88,15 @@ export function PropertyRow({
8788
</span>
8889
<button
8990
type="button"
91+
role="switch"
92+
aria-checked={isVisible}
9093
onClick={() => onCommit(isVisible ? "hidden" : "visible")}
91-
className="flex-shrink-0 rounded-full transition-all duration-150 relative"
94+
className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
9295
style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
9396
title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
9497
>
9598
<span
96-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
99+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
97100
style={{
98101
width: 12,
99102
height: 12,

packages/studio/src/components/editor/ArcPathControls.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
4545
<span className={LABEL}>Arc Motion</span>
4646
<button
4747
type="button"
48+
role="switch"
49+
aria-checked={Boolean(arcPath.enabled)}
50+
aria-label="Arc motion"
4851
onClick={handleToggle}
4952
disabled={disabled}
50-
className="relative rounded-full transition-all duration-150"
53+
className="relative rounded-full transition-colors duration-200"
5154
style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
5255
title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
5356
>
5457
<span
55-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
58+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
5659
style={{
5760
width: 12,
5861
height: 12,
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
6972
<span className={LABEL}>Auto-Rotate</span>
7073
<button
7174
type="button"
75+
role="switch"
76+
aria-checked={Boolean(arcPath.autoRotate)}
77+
aria-label="Auto-rotate along path"
7278
onClick={handleAutoRotate}
7379
disabled={disabled}
74-
className="relative rounded-full transition-all duration-150"
80+
className="relative rounded-full transition-colors duration-200"
7581
style={{
7682
width: 28,
7783
height: 16,
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
8490
}
8591
>
8692
<span
87-
className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
93+
className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
8894
style={{
8995
width: 12,
9096
height: 12,
@@ -113,6 +119,7 @@ export const ArcPathControls = memo(function ArcPathControls({
113119
)}
114120
</div>
115121
<SliderControl
122+
ariaLabel="Curviness"
116123
value={seg.curviness}
117124
min={0}
118125
max={3}

packages/studio/src/components/editor/BlockParamsPanel.tsx

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import { memo, useState, useCallback } from "react";
1+
import { memo, useCallback, useEffect, useRef, useState } from "react";
22
import type { BlockParam } from "@hyperframes/core/registry";
3+
import { useFileManagerContextOptional } from "../../contexts/FileManagerContext";
4+
import { useStudioPlaybackContext } from "../../contexts/StudioContext";
35

46
interface BlockParamsPanelProps {
57
blockName: string;
@@ -9,23 +11,75 @@ interface BlockParamsPanelProps {
911
onClose: () => void;
1012
}
1113

14+
type CommitState = { tone: "idle" | "saving" | "saved" | "error"; message?: string };
15+
1216
export const BlockParamsPanel = memo(function BlockParamsPanel({
1317
blockTitle,
1418
params,
15-
compositionPath: _compositionPath,
19+
compositionPath,
1620
onClose,
1721
}: BlockParamsPanelProps) {
22+
const fileManager = useFileManagerContextOptional();
23+
const { setRefreshKey } = useStudioPlaybackContext();
1824
const [values, setValues] = useState<Record<string, string>>(() => {
1925
const initial: Record<string, string> = {};
2026
for (const p of params) {
2127
initial[p.key] = p.default;
2228
}
2329
return initial;
2430
});
31+
// Last value actually written to the block file per param — the literal we
32+
// substitute when the next commit rewrites the file.
33+
const appliedRef = useRef<Record<string, string>>(
34+
Object.fromEntries(params.map((p) => [p.key, p.default])),
35+
);
36+
const [commitState, setCommitState] = useState<CommitState>({ tone: "idle" });
37+
const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
38+
39+
useEffect(
40+
() => () => {
41+
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
42+
},
43+
[],
44+
);
2545

26-
const handleChange = useCallback((key: string, value: string) => {
27-
setValues((prev) => ({ ...prev, [key]: value }));
28-
}, []);
46+
const commitParam = useCallback(
47+
async (key: string, nextValue: string) => {
48+
if (!fileManager) return;
49+
const previous = appliedRef.current[key];
50+
if (previous === undefined || previous === nextValue || !nextValue.trim()) return;
51+
setCommitState({ tone: "saving" });
52+
try {
53+
const content = await fileManager.readProjectFile(compositionPath);
54+
if (!content.includes(previous)) {
55+
setCommitState({
56+
tone: "error",
57+
message: `Couldn't find the current value in ${compositionPath} — it may have been edited by hand.`,
58+
});
59+
return;
60+
}
61+
const nextContent = content.split(previous).join(nextValue);
62+
await fileManager.writeProjectFile(compositionPath, nextContent);
63+
appliedRef.current[key] = nextValue;
64+
setCommitState({ tone: "saved" });
65+
setRefreshKey((k) => k + 1);
66+
} catch {
67+
setCommitState({ tone: "error", message: "Couldn't save the block file. Retry?" });
68+
}
69+
},
70+
[fileManager, compositionPath, setRefreshKey],
71+
);
72+
73+
const handleChange = useCallback(
74+
(key: string, value: string) => {
75+
setValues((prev) => ({ ...prev, [key]: value }));
76+
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
77+
commitTimerRef.current = setTimeout(() => {
78+
void commitParam(key, value);
79+
}, 300);
80+
},
81+
[commitParam],
82+
);
2983

3084
return (
3185
<div className="flex flex-col h-full">
@@ -34,7 +88,8 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
3488
<button
3589
type="button"
3690
onClick={onClose}
37-
className="text-neutral-500 hover:text-neutral-300 transition-colors"
91+
aria-label="Close block parameters"
92+
className="p-1.5 -m-1 text-neutral-500 hover:text-neutral-300 active:scale-[0.97] transition-colors"
3893
>
3994
<svg
4095
width="14"
@@ -56,14 +111,38 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
56111
<div className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
57112
Parameters
58113
</div>
114+
{params.length === 0 && (
115+
<div className="text-[10px] text-neutral-500">This block has no editable parameters.</div>
116+
)}
117+
{!fileManager && params.length > 0 && (
118+
<div className="text-[10px] text-amber-400/90">
119+
Block params can't be edited here — no project file access.
120+
</div>
121+
)}
59122
{params.map((param) => (
60123
<ParamControl
61124
key={param.key}
62125
param={param}
63126
value={values[param.key] ?? param.default}
127+
disabled={!fileManager}
64128
onChange={(v) => handleChange(param.key, v)}
65129
/>
66130
))}
131+
{commitState.tone === "saving" && (
132+
<div className="text-[10px] text-neutral-500" role="status">
133+
Saving…
134+
</div>
135+
)}
136+
{commitState.tone === "saved" && (
137+
<div className="text-[10px] text-emerald-500/90" role="status">
138+
Saved to {compositionPath}
139+
</div>
140+
)}
141+
{commitState.tone === "error" && (
142+
<div className="text-[10px] text-red-400" role="alert">
143+
{commitState.message}
144+
</div>
145+
)}
67146
</div>
68147
</div>
69148
);
@@ -72,10 +151,12 @@ export const BlockParamsPanel = memo(function BlockParamsPanel({
72151
function ParamControl({
73152
param,
74153
value,
154+
disabled,
75155
onChange,
76156
}: {
77157
param: BlockParam;
78158
value: string;
159+
disabled?: boolean;
79160
onChange: (value: string) => void;
80161
}) {
81162
return (
@@ -87,14 +168,18 @@ function ParamControl({
87168
<input
88169
type="color"
89170
value={value}
171+
disabled={disabled}
172+
aria-label={`${param.label} color`}
90173
onChange={(e) => onChange(e.target.value)}
91-
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer"
174+
className="w-7 h-7 rounded border border-neutral-700 bg-transparent cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
92175
/>
93176
<input
94177
type="text"
95178
value={value}
179+
disabled={disabled}
180+
aria-label={`${param.label} value`}
96181
onChange={(e) => onChange(e.target.value)}
97-
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"
182+
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"
98183
/>
99184
</div>
100185
)}
@@ -107,8 +192,10 @@ function ParamControl({
107192
max={param.max ?? 100}
108193
step={param.step ?? 1}
109194
value={value}
195+
disabled={disabled}
196+
aria-label={param.label}
110197
onChange={(e) => onChange(e.target.value)}
111-
className="flex-1"
198+
className="flex-1 disabled:cursor-not-allowed disabled:opacity-50"
112199
/>
113200
<span className="text-[10px] text-neutral-400 w-8 text-right tabular-nums">{value}</span>
114201
</div>
@@ -118,16 +205,20 @@ function ParamControl({
118205
<input
119206
type="text"
120207
value={value}
208+
disabled={disabled}
209+
aria-label={param.label}
121210
onChange={(e) => onChange(e.target.value)}
122-
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"
211+
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"
123212
/>
124213
)}
125214

126215
{param.type === "select" && param.options && (
127216
<select
128217
value={value}
218+
disabled={disabled}
219+
aria-label={param.label}
129220
onChange={(e) => onChange(e.target.value)}
130-
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"
221+
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"
131222
>
132223
{param.options.map((opt) => (
133224
<option key={opt.value} value={opt.value}>

packages/studio/src/components/editor/BorderRadiusEditor.tsx

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -79,34 +79,15 @@ export function BorderRadiusEditor({
7979
stroke="rgba(255,255,255,0.24)"
8080
strokeWidth={1.5}
8181
/>
82-
<circle
83-
cx={sTL}
84-
cy={sTL}
85-
r={3}
86-
fill={linked ? "#3b82f6" : "#a78bfa"}
87-
className="cursor-pointer"
88-
/>
89-
<circle
90-
cx={PREVIEW_W - sTR}
91-
cy={sTR}
92-
r={3}
93-
fill={linked ? "#3b82f6" : "#a78bfa"}
94-
className="cursor-pointer"
95-
/>
82+
<circle cx={sTL} cy={sTL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
83+
<circle cx={PREVIEW_W - sTR} cy={sTR} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
9684
<circle
9785
cx={PREVIEW_W - sBR}
9886
cy={PREVIEW_H - sBR}
9987
r={3}
10088
fill={linked ? "#3b82f6" : "#a78bfa"}
101-
className="cursor-pointer"
102-
/>
103-
<circle
104-
cx={sBL}
105-
cy={PREVIEW_H - sBL}
106-
r={3}
107-
fill={linked ? "#3b82f6" : "#a78bfa"}
108-
className="cursor-pointer"
10989
/>
90+
<circle cx={sBL} cy={PREVIEW_H - sBL} r={3} fill={linked ? "#3b82f6" : "#a78bfa"} />
11091
</svg>
11192

11293
<button

packages/studio/src/components/editor/DomEditOverlay.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
494494
<div className="absolute left-1/2 top-3 bottom-0 w-px -translate-x-1/2 bg-studio-accent/60" />
495495
<button
496496
type="button"
497-
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)]"
497+
tabIndex={-1}
498+
className="pointer-events-auto absolute left-1/2 top-0 h-3 w-3 -translate-x-1/2 rounded-full border border-studio-accent bg-studio-accent p-0 shadow-[0_0_0_2px_rgba(60,230,172,0.18)] before:absolute before:-inset-2 before:content-['']"
498499
style={{ cursor: "grab", touchAction: "none" }}
499500
title="Rotate"
500501
aria-label="Rotate selection"
@@ -542,7 +543,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
542543
>
543544
{allowCanvasMovement && selection.capabilities.canApplyManualSize && (
544545
<div
545-
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
546+
className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60 before:absolute before:-inset-2.5 before:content-['']"
546547
style={{ cursor: "se-resize", touchAction: "none" }}
547548
onPointerDown={(e) => {
548549
e.stopPropagation();

0 commit comments

Comments
 (0)