diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx
index 76f4048c1..5bbaaf29e 100644
--- a/packages/studio/src/App.tsx
+++ b/packages/studio/src/App.tsx
@@ -334,6 +334,7 @@ export function StudioApp() {
queueDomEditSave: previewPersistence.queueDomEditSave,
readProjectFile: fileManager.readProjectFile,
writeProjectFile: fileManager.writeProjectFile,
+ updateEditingFileContent: fileManager.updateEditingFileContent,
domEditSaveTimestampRef,
editHistory: { recordEdit: editHistory.recordEdit },
fileTree: fileManager.fileTree,
diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx
index 1a7cf8c28..9384950ab 100644
--- a/packages/studio/src/components/StudioHeader.tsx
+++ b/packages/studio/src/components/StudioHeader.tsx
@@ -171,10 +171,10 @@ export function StudioHeader({
void handleUndo();
}}
disabled={!editHistory.canUndo}
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
+ className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canUndo
- ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
- : "border-neutral-900 text-neutral-700"
+ ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
+ : "text-neutral-700 cursor-default"
}`}
title={
editHistory.undoLabel
@@ -192,10 +192,10 @@ export function StudioHeader({
void handleRedo();
}}
disabled={!editHistory.canRedo}
- className={`h-7 w-7 flex items-center justify-center rounded-md border transition-colors ${
+ className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
editHistory.canRedo
- ? "border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800"
- : "border-neutral-900 text-neutral-700"
+ ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
+ : "text-neutral-700 cursor-default"
}`}
title={
editHistory.redoLabel
@@ -215,7 +215,7 @@ export function StudioHeader({
}}
onFocus={refreshCaptureFrameTime}
onPointerDown={refreshCaptureFrameTime}
- className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border border-neutral-700 text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
+ className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800"
title="Capture current frame"
aria-label="Capture current frame"
>
diff --git a/packages/studio/src/components/editor/BorderRadiusEditor.tsx b/packages/studio/src/components/editor/BorderRadiusEditor.tsx
new file mode 100644
index 000000000..c4f697a5f
--- /dev/null
+++ b/packages/studio/src/components/editor/BorderRadiusEditor.tsx
@@ -0,0 +1,209 @@
+import { useCallback, useState } from "react";
+import { MetricField } from "./propertyPanelPrimitives";
+import { formatNumericValue, parseNumericValue, RESPONSIVE_GRID } from "./propertyPanelHelpers";
+
+type Corner = "tl" | "tr" | "br" | "bl";
+
+interface BorderRadiusEditorProps {
+ tl: number;
+ tr: number;
+ br: number;
+ bl: number;
+ disabled?: boolean;
+ onCommit: (corner: Corner | "all", value: number) => void;
+}
+
+const PREVIEW_W = 72;
+const PREVIEW_H = 52;
+const MAX_RADIUS = 26;
+
+function clampRadius(v: number): number {
+ return Math.max(0, Math.min(MAX_RADIUS, v));
+}
+
+function scaleRadius(v: number, maxPx: number): number {
+ if (maxPx <= 0) return 0;
+ return clampRadius(Math.round((v / Math.max(maxPx, 1)) * MAX_RADIUS));
+}
+
+export function BorderRadiusEditor({
+ tl,
+ tr,
+ br,
+ bl,
+ disabled,
+ onCommit,
+}: BorderRadiusEditorProps) {
+ const uniform = tl === tr && tr === br && br === bl;
+ const [linked, setLinked] = useState(uniform);
+
+ const maxVal = Math.max(tl, tr, br, bl, 1);
+ const sTL = scaleRadius(tl, maxVal);
+ const sTR = scaleRadius(tr, maxVal);
+ const sBR = scaleRadius(br, maxVal);
+ const sBL = scaleRadius(bl, maxVal);
+
+ const handleCornerCommit = useCallback(
+ (corner: Corner, raw: string) => {
+ const v = parseNumericValue(raw) ?? 0;
+ if (linked) {
+ onCommit("all", v);
+ } else {
+ onCommit(corner, v);
+ }
+ },
+ [linked, onCommit],
+ );
+
+ const handleToggleLinked = useCallback(() => {
+ if (!linked && !uniform) {
+ onCommit("all", tl);
+ }
+ setLinked((l) => !l);
+ }, [linked, uniform, tl, onCommit]);
+
+ const path = buildRoundedRectPath(PREVIEW_W, PREVIEW_H, sTL, sTR, sBR, sBL);
+
+ return (
+
+
+
+
+
+
+
+ {linked ? (
+
handleCornerCommit("tl", next)}
+ />
+ ) : (
+
+ handleCornerCommit("tl", next)}
+ />
+ handleCornerCommit("tr", next)}
+ />
+ handleCornerCommit("bl", next)}
+ />
+ handleCornerCommit("br", next)}
+ />
+
+ )}
+
+ );
+}
+
+function buildRoundedRectPath(
+ w: number,
+ h: number,
+ tl: number,
+ tr: number,
+ br: number,
+ bl: number,
+): string {
+ return [
+ `M ${tl} 0`,
+ `L ${w - tr} 0`,
+ `Q ${w} 0 ${w} ${tr}`,
+ `L ${w} ${h - br}`,
+ `Q ${w} ${h} ${w - br} ${h}`,
+ `L ${bl} ${h}`,
+ `Q 0 ${h} 0 ${h - bl}`,
+ `L 0 ${tl}`,
+ `Q 0 0 ${tl} 0`,
+ "Z",
+ ].join(" ");
+}
diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx
index aafee19da..551d734be 100644
--- a/packages/studio/src/components/editor/PropertyPanel.tsx
+++ b/packages/studio/src/components/editor/PropertyPanel.tsx
@@ -217,6 +217,34 @@ export const PropertyPanel = memo(function PropertyPanel({
}
})();
+ const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
+ if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
+ const hasBRProp = gsapAnimations.some(
+ (a) =>
+ "borderRadius" in a.properties ||
+ a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
+ );
+ if (!hasBRProp) return null;
+ }
+ const iframe = previewIframeRef?.current;
+ const selector = element.id ? `#${element.id}` : element.selector;
+ if (!iframe?.contentDocument || !selector) return null;
+ try {
+ const el = iframe.contentDocument.querySelector(selector);
+ if (!el) return null;
+ const cs = iframe.contentWindow!.getComputedStyle(el);
+ const parse = (v: string) => Number.parseFloat(v) || 0;
+ return {
+ tl: parse(cs.borderTopLeftRadius),
+ tr: parse(cs.borderTopRightRadius),
+ br: parse(cs.borderBottomRightRadius),
+ bl: parse(cs.borderBottomLeftRadius),
+ };
+ } catch {
+ return null;
+ }
+ })();
+
const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
@@ -544,6 +572,7 @@ export const PropertyPanel = memo(function PropertyPanel({
assets={assets}
onSetStyle={onSetStyle}
onImportAssets={onImportAssets}
+ gsapBorderRadius={gsapBorderRadius}
/>
)}
diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts
index 96f69ff8c..b26a3f2d1 100644
--- a/packages/studio/src/components/editor/domEditingTypes.ts
+++ b/packages/studio/src/components/editor/domEditingTypes.ts
@@ -29,6 +29,10 @@ export const CURATED_STYLE_PROPERTIES = [
"opacity",
"mix-blend-mode",
"border-radius",
+ "border-top-left-radius",
+ "border-top-right-radius",
+ "border-bottom-right-radius",
+ "border-bottom-left-radius",
"border-width",
"border-style",
"border-color",
diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
index 5702fe764..a755824d5 100644
--- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
+++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx
@@ -33,6 +33,7 @@ import {
} from "./propertyPanelPrimitives";
import { ColorField } from "./propertyPanelColor";
import { GradientField, ImageFillField } from "./propertyPanelFill";
+import { BorderRadiusEditor } from "./BorderRadiusEditor";
export function StyleSections({
projectId,
@@ -41,6 +42,7 @@ export function StyleSections({
assets,
onSetStyle,
onImportAssets,
+ gsapBorderRadius,
}: {
projectId: string;
element: DomEditSelection;
@@ -48,10 +50,19 @@ export function StyleSections({
assets: string[];
onSetStyle: (prop: string, value: string) => void | Promise;
onImportAssets?: (files: FileList) => Promise;
+ gsapBorderRadius?: { tl: number; tr: number; br: number; bl: number } | null;
}) {
const styleEditingDisabled = !element.capabilities.canEditStyles;
const isFlex = styles.display === "flex" || styles.display === "inline-flex";
const radiusValue = parseNumericValue(styles["border-radius"]) ?? 0;
+ const radiusTL =
+ gsapBorderRadius?.tl ?? parseNumericValue(styles["border-top-left-radius"]) ?? radiusValue;
+ const radiusTR =
+ gsapBorderRadius?.tr ?? parseNumericValue(styles["border-top-right-radius"]) ?? radiusValue;
+ const radiusBR =
+ gsapBorderRadius?.br ?? parseNumericValue(styles["border-bottom-right-radius"]) ?? radiusValue;
+ const radiusBL =
+ gsapBorderRadius?.bl ?? parseNumericValue(styles["border-bottom-left-radius"]) ?? radiusValue;
const opacityValue = Math.round((parseNumericValue(styles.opacity) ?? 1) * 100);
const borderWidthValue =
parsePxMetricValue(styles["border-width"] ?? "") ??
@@ -155,15 +166,26 @@ export function StyleSections({
{hasVisualBackground && (
} defaultCollapsed>
- `${formatNumericValue(next)}px`}
- onCommit={(next) => onSetStyle("border-radius", `${formatNumericValue(next)}px`)}
+ onCommit={(corner, value) => {
+ const px = `${formatNumericValue(value)}px`;
+ if (corner === "all") {
+ onSetStyle("border-radius", px);
+ } else {
+ const prop = {
+ tl: "border-top-left-radius",
+ tr: "border-top-right-radius",
+ br: "border-bottom-right-radius",
+ bl: "border-bottom-left-radius",
+ }[corner];
+ onSetStyle(prop, px);
+ }
+ }}
/>
)}
diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx
index f634eb8a1..51fe9308b 100644
--- a/packages/studio/src/contexts/FileManagerContext.tsx
+++ b/packages/studio/src/contexts/FileManagerContext.tsx
@@ -26,6 +26,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
+ updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
@@ -64,6 +65,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
+ updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
@@ -96,6 +98,7 @@ export function FileManagerProvider({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
+ updateEditingFileContent,
revealSourceOffset,
openSourceForSelection,
handleFileSelect,
diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts
index 1ee449de8..cf788ec4f 100644
--- a/packages/studio/src/hooks/useDomEditSession.ts
+++ b/packages/studio/src/hooks/useDomEditSession.ts
@@ -59,6 +59,7 @@ export interface UseDomEditSessionParams {
queueDomEditSave: (save: () => Promise) => Promise;
readProjectFile: (path: string) => Promise;
writeProjectFile: (path: string, content: string) => Promise;
+ updateEditingFileContent: (path: string, content: string) => void;
domEditSaveTimestampRef: React.MutableRefObject;
editHistory: { recordEdit: (entry: RecordEditInput) => Promise };
fileTree: string[];
@@ -100,6 +101,7 @@ export function useDomEditSession({
queueDomEditSave,
readProjectFile: _readProjectFile,
writeProjectFile,
+ updateEditingFileContent,
domEditSaveTimestampRef,
editHistory,
fileTree,
@@ -224,6 +226,18 @@ export function useDomEditSession({
const { version: gsapCacheVersion, bump: bumpGsapCache } = useGsapCacheVersion();
+ // Bump GSAP cache when refreshKey changes (code-tab edits trigger iframe
+ // reload via refreshKey but don't go through commitMutation, so the cache
+ // would otherwise retain stale keyframe entries).
+ const prevRefreshKeyRef = useRef(refreshKey);
+ // eslint-disable-next-line no-restricted-syntax
+ useEffect(() => {
+ if (refreshKey !== prevRefreshKeyRef.current) {
+ prevRefreshKeyRef.current = refreshKey;
+ bumpGsapCache();
+ }
+ }, [refreshKey, bumpGsapCache]);
+
const gsapSourceFile = domEditSelection?.sourceFile || activeCompPath || "index.html";
usePopulateKeyframeCacheForFile(
@@ -268,6 +282,7 @@ export function useDomEditSession({
domEditSaveTimestampRef,
reloadPreview,
onCacheInvalidate: bumpGsapCache,
+ onFileContentChanged: updateEditingFileContent,
});
// ── Commit handlers (delegated to useDomEditCommits) ──
diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts
index caef9eeb9..f977d8bd9 100644
--- a/packages/studio/src/hooks/useFileManager.ts
+++ b/packages/studio/src/hooks/useFileManager.ts
@@ -108,6 +108,12 @@ export function useFileManager({
}
}, []);
+ const updateEditingFileContent = useCallback((path: string, content: string) => {
+ if (editingPathRef.current === path) {
+ setEditingFile({ path, content });
+ }
+ }, []);
+
const readOptionalProjectFile = useCallback(async (path: string): Promise => {
const pid = projectIdRef.current;
if (!pid) throw new Error("No active project");
@@ -460,6 +466,7 @@ export function useFileManager({
readProjectFile,
writeProjectFile,
readOptionalProjectFile,
+ updateEditingFileContent,
// Click-to-source
revealSourceOffset,
diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts
index 4c5908f4a..fbc8acd86 100644
--- a/packages/studio/src/hooks/useGsapScriptCommits.ts
+++ b/packages/studio/src/hooks/useGsapScriptCommits.ts
@@ -108,6 +108,7 @@ interface GsapScriptCommitsParams {
domEditSaveTimestampRef: React.MutableRefObject;
reloadPreview: () => void;
onCacheInvalidate: () => void;
+ onFileContentChanged?: (path: string, content: string) => void;
}
const DEBOUNCE_MS = 150;
@@ -121,6 +122,7 @@ export function useGsapScriptCommits({
domEditSaveTimestampRef,
reloadPreview,
onCacheInvalidate,
+ onFileContentChanged,
}: GsapScriptCommitsParams) {
const pendingPropertyEditRef = useRef<{
selection: DomEditSelection;
@@ -164,14 +166,28 @@ export function useGsapScriptCommits({
onCacheInvalidate();
+ if (result.after != null) {
+ onFileContentChanged?.(targetPath, result.after);
+ }
+
if (result.parsed?.animations) {
const { setKeyframeCache } = usePlayerStore.getState();
+ const idsWithKeyframes = new Set();
for (const anim of result.parsed.animations) {
- if (!anim.keyframes) continue;
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
if (!id) continue;
- setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
+ if (anim.keyframes) {
+ idsWithKeyframes.add(id);
+ setKeyframeCache(`${targetPath}#${id}`, anim.keyframes);
+ if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, anim.keyframes);
+ }
+ }
+ const targetId =
+ (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
+ selection.id;
+ if (targetId && !idsWithKeyframes.has(targetId)) {
+ setKeyframeCache(`${targetPath}#${targetId}`, undefined);
+ if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
}
}
@@ -195,6 +211,7 @@ export function useGsapScriptCommits({
domEditSaveTimestampRef,
reloadPreview,
onCacheInvalidate,
+ onFileContentChanged,
],
);
diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts
index b2c2d5280..f47b806cf 100644
--- a/packages/studio/src/hooks/useGsapTweenCache.ts
+++ b/packages/studio/src/hooks/useGsapTweenCache.ts
@@ -224,7 +224,14 @@ export function usePopulateKeyframeCacheForFile(
const sf = sourceFile;
fetchParsedAnimations(projectId, sf).then((parsed) => {
if (!parsed) return;
- const { setKeyframeCache } = usePlayerStore.getState();
+ const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
+ const sfPrefix = `${sf}#`;
+ const fallbackPrefix = "index.html#";
+ for (const key of keyframeCache.keys()) {
+ if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
+ setKeyframeCache(key, undefined);
+ }
+ }
for (const anim of parsed.animations) {
const id = extractIdFromSelector(anim.targetSelector);
if (!id || !anim.keyframes) continue;