Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0ff2056
feat(studio): draggable 3D-transform cube in the design panel
miguel-heygen Jun 25, 2026
610cdd5
feat(studio): polish 3D cube — collapsed by default, compact lit cube…
miguel-heygen Jun 25, 2026
47d51e1
fix(studio): persist static 3D transform + refine cube edges
miguel-heygen Jun 25, 2026
d1b637e
feat(studio): 3D transform — keyframe diamonds, flash-free commits, i…
miguel-heygen Jun 25, 2026
927780e
feat(studio): 3D cube X/Y/Z axis gizmo + gated flash-diagnostic logs
miguel-heygen Jun 25, 2026
3541898
fix(studio): make the 3D cube mirror the element's orientation 1:1
miguel-heygen Jun 25, 2026
18047ea
fix(studio): stop design-panel flicker — read transform channels live
miguel-heygen Jun 25, 2026
da47724
refactor(studio): extract collectPanelPropKeys to keep panel reader u…
miguel-heygen Jun 25, 2026
9895fd9
feat(studio): keyframable 3D transforms — convert a static set to key…
miguel-heygen Jun 25, 2026
fdb6562
feat(studio): keyframe toggle on the 3D cube
miguel-heygen Jun 25, 2026
2eea35a
feat(studio): auto-keyframe 3D transforms on animated elements + stop…
miguel-heygen Jun 25, 2026
ad489b5
fix(studio): cube writes one keyframe per drag (no duplicate keyframes)
miguel-heygen Jun 25, 2026
5964df0
refactor(studio): extract AudioRow from AssetsTab to satisfy file-siz…
miguel-heygen Jun 25, 2026
4c0803b
fix(studio): self-heal stale animationId on 3D property commit
miguel-heygen Jun 25, 2026
5754e3e
fix(studio): batch the 3D reset into one commit (was six flashes)
miguel-heygen Jun 25, 2026
0df8e22
fix(studio): 3D-edit a static element writes a set, not keyframes
miguel-heygen Jun 25, 2026
7a5d4bb
feat(studio): instant 3D keyframe edits via in-place tween rebuild
miguel-heygen Jun 25, 2026
84f6118
feat(studio): static 3D transform persists as off-timeline gsap.set (…
miguel-heygen Jun 25, 2026
35a5685
fix(studio): static manual drag persists as off-timeline gsap.set, in…
miguel-heygen Jun 25, 2026
3e8f938
fix(studio): a base gsap.set shows no keyframe diamond (timeline + pa…
miguel-heygen Jun 25, 2026
4c63342
fix(studio): a static set never shows a keyframe diamond (timeline + …
miguel-heygen Jun 25, 2026
caf05ed
fix(studio): batch set-property edits (reset 3D no longer 404s)
miguel-heygen Jun 25, 2026
9026af4
style(studio): fix format + trim 3D-patch helper complexity
miguel-heygen Jun 25, 2026
4257c6c
chore(studio): remove [hf-3d:*] debug logs (3D transform verified wor…
miguel-heygen Jun 25, 2026
b57f1fe
chore(studio): strategic [hf-pos:*] logs for position-commit path audit
miguel-heygen Jun 25, 2026
8d87930
fix(studio): route multi-select group drag through GSAP code path
miguel-heygen Jun 25, 2026
361485a
feat(studio): live candidate highlight while marquee-selecting
miguel-heygen Jun 25, 2026
8fa7f62
chore(studio): remove temporary [hf-pos:*] position-path debug logs
miguel-heygen Jun 25, 2026
dfcb1d6
fix(studio): marquee selects/highlights elements at their real positions
miguel-heygen Jun 25, 2026
4963ef3
fix(studio): off-canvas elements no longer render a selection-style b…
miguel-heygen Jun 25, 2026
d8cb317
chore(studio): remove [hf-marquee:*] debug logs
miguel-heygen Jun 25, 2026
fb8cae7
fix(studio): convert a global gsap.set to a seekable timeline tween +…
miguel-heygen Jun 25, 2026
51110a8
chore(studio): green the CI gate + 3D panel expanded by default
miguel-heygen Jun 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/parsers/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const SUPPORTED_PROPS = [
"rotationY",
"rotationZ",
"perspective",
"transformPerspective",
"transformOrigin",
// Visibility
"opacity",
Expand Down
95 changes: 95 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,47 @@ describe("stagger/yoyo/repeat round-trip", () => {
expect(updatedScript).toContain("opacity: 0.5");
});

it("converts a static set into a keyframed to() with a duration (keyframable 3D)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.set("#card", { rotationX: 50, rotationY: 20, immediateRender: true }, 0);
`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0].id;
const result = convertToKeyframesInScript(script, animId, undefined, 4);
// Flips set → to, drops the hold marker, gains a duration + keyframes.
expect(result).toContain('tl.to("#card"');
expect(result).not.toContain("immediateRender");
expect(result).toContain("duration: 4");
expect(result).toContain("keyframes:");
// Both endpoints start at the set's value (visual unchanged until edited).
const reparsed = parseGsapScript(result).animations[0];
expect(reparsed.keyframes).toBeTruthy();
expect(reparsed.keyframes!.keyframes[0]!.properties.rotationX).toBe(50);
expect(reparsed.keyframes!.keyframes.at(-1)!.properties.rotationX).toBe(50);
});

it("converts a GLOBAL gsap.set into a timeline-rooted to() (seekable, not gsap.to)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
gsap.set("#card", { rotationX: 50, rotationY: 20 });
`;
const parsed = parseGsapScript(script);
const animId = parsed.animations[0].id;
expect(parsed.animations[0].global).toBe(true);
const result = convertToKeyframesInScript(script, animId, undefined, 4);
// Must re-root onto the master timeline (tl.to), NOT emit an off-timeline
// gsap.to that fires once at load and can't be seeked/rendered.
expect(result).toMatch(/tl\.to\(\s*"#card"/);
expect(result).not.toMatch(/gsap\.to\(/);
expect(result).toContain("duration: 4");
expect(result).toContain("keyframes:");
// Re-parsed tween is a real timeline keyframe tween, no longer global.
const reparsed = parseGsapScript(result).animations[0];
expect(reparsed.keyframes).toBeTruthy();
expect(reparsed.global).toBeFalsy();
});

it("apply-to-all (resetKeyframeEases) sets easeEach and strips every per-keyframe ease", () => {
const script = `
const tl = gsap.timeline({ paused: true });
Expand Down Expand Up @@ -2812,3 +2853,57 @@ tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`;
expect(parsed.animations[1].position).toBe("+=0.5");
});
});

describe("base gsap.set (off-timeline global hold)", () => {
const SCRIPT = `
const tl = gsap.timeline({ paused: true });
gsap.set("#box", { rotationX: 17, rotationY: 93 });
tl.to("#box", { x: 260, duration: 1 }, 0.3);
window.__timelines = { main: tl };
`;

it("parses a string-literal gsap.set as a global set animation", () => {
const anims = parseGsapScript(SCRIPT).animations.filter((a) => a.targetSelector === "#box");
const set = anims.find((a) => a.method === "set");
expect(set?.global).toBe(true);
expect(set?.properties).toEqual({ rotationX: 17, rotationY: 93 });
expect(anims.find((a) => a.method === "to")?.global).toBeUndefined();
});

it("creates a base gsap.set (not tl.set) when global is set", () => {
const base = `const tl = gsap.timeline({ paused: true });\ntl.to("#box", { x: 1, duration: 1 }, 0);\nwindow.__timelines = { main: tl };`;
const { script } = addAnimationToScript(base, {
targetSelector: "#box",
method: "set",
position: 0,
properties: { rotationX: 30 },
global: true,
});
expect(script).toContain('gsap.set("#box"');
expect(script).not.toContain('tl.set("#box"');
});

it("updates a global set in place, keeping it gsap.set", () => {
const set = parseGsapScript(SCRIPT).animations.find(
(a) => a.targetSelector === "#box" && a.method === "set",
)!;
const updated = updateAnimationInScript(SCRIPT, set.id, {
properties: { rotationX: 99, rotationY: 93 },
});
expect(updated).toContain('gsap.set("#box"');
expect(updated).toContain("99");
expect(updated).not.toContain('tl.set("#box"');
});

it("leaves a VARIABLE-target gsap.set as surrounding source (not parsed)", () => {
const script = `
const tl = gsap.timeline({ paused: true });
const el = document.querySelector("#box");
gsap.set(el, { rotationX: 5 });
tl.to("#box", { x: 10, duration: 1 }, 0);
window.__timelines = { main: tl };
`;
const sets = parseGsapScript(script).animations.filter((a) => a.method === "set");
expect(sets).toHaveLength(0);
});
});
53 changes: 49 additions & 4 deletions packages/core/src/parsers/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@ interface TweenCallInfo {
varsArg: AstNode;
fromArg?: AstNode;
positionArg?: AstNode;
/** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */
global?: boolean;
}

/**
Expand All @@ -465,10 +467,24 @@ function findAllTweenCalls(
visitCallExpression(path: AstPath) {
const node = path.node;
const callee = node.callee;
// A base `gsap.set("#sel", props)` is an off-timeline static hold (no position,
// no keyframe marker). Treat it as an editable `set` animation so a static
// value (e.g. a 3D transform) round-trips and re-edits in place. Restricted to
// a STRING-LITERAL selector: variable-target `gsap.set(el, ...)` holds stay
// opaque surrounding source (editing them by selector would be ambiguous).
const gsapSetArg = node.arguments?.[0];
const isGlobalSet =
callee?.type === "MemberExpression" &&
callee.object?.type === "Identifier" &&
callee.object.name === "gsap" &&
callee.property?.type === "Identifier" &&
callee.property.name === "set" &&
(gsapSetArg?.type === "StringLiteral" ||
(gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string"));
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
isTimelineRootedCall(node, timelineVar)
(isTimelineRootedCall(node, timelineVar) || isGlobalSet)
) {
const method = callee.property.name;
if (!GSAP_METHODS.has(method)) {
Expand Down Expand Up @@ -501,6 +517,7 @@ function findAllTweenCalls(
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
...(isGlobalSet ? { global: true } : {}),
});
}
}
Expand Down Expand Up @@ -968,6 +985,7 @@ function tweenCallToAnimation(
group = classifyTweenPropertyGroup(kfProps);
}
if (group) anim.propertyGroup = group;
if (call.global) anim.global = true;
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
Expand Down Expand Up @@ -1306,8 +1324,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
const entries = Object.entries(props).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
// immediateRender forces GSAP to apply the set when added to the timeline,
// not on the first seek — without it, tl.set at position 0 on a paused
// timeline is invisible until the playhead moves past 0.
if (anim.method === "set") entries.push("immediateRender: true");
// timeline is invisible until the playhead moves past 0. A base `gsap.set`
// already runs immediately, so it doesn't need (or get) the flag.
if (anim.method === "set" && !anim.global) entries.push("immediateRender: true");
if (anim.extras) {
for (const [k, v] of Object.entries(anim.extras)) {
entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`);
Expand All @@ -1324,6 +1343,10 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
const fromCode = `{ ${fromEntries.join(", ")} }`;
return `${timelineVar}.fromTo(${selector}, ${fromCode}, ${objCode}, ${posCode});`;
}
// A base `gsap.set` is off the timeline: no timeline var, no position arg.
if (anim.method === "set" && anim.global) {
return `gsap.set(${selector}, ${objCode});`;
}
return `${timelineVar}.${anim.method}(${selector}, ${objCode}, ${posCode});`;
}

Expand Down Expand Up @@ -2302,12 +2325,13 @@ export function convertToKeyframesInScript(
script: string,
animationId: string,
resolvedFromValues?: Record<string, number | string>,
setDuration = 1,
): string {
let loc = locateAnimationWithFallback(script, animationId);
if (!loc) return script;

const anim = loc.target.animation;
if (anim.keyframes || anim.method === "set") return script;
if (anim.keyframes) return script;

const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues);
const varsArg = loc.target.call.varsArg;
Expand All @@ -2326,6 +2350,27 @@ export function convertToKeyframesInScript(
if (anim.method === "fromTo") loc.target.call.node.arguments.splice(1, 1);
}

// A static `set` becomes an animatable `to`: flip the method, drop the
// immediateRender hold marker, and give it a real duration so the keyframes
// span time. This is what makes a static 3D transform keyframeable.
if (anim.method === "set") {
// A GLOBAL `gsap.set(...)` is off-timeline; flipping only the method would
// emit `gsap.to(...)`, which fires once at load and is NOT on the paused
// master timeline (the engine can't seek/render it). Re-root it onto the
// timeline var and add the position arg (a gsap.set has none) so the
// converted tween is seekable. A `tl.set` already has the right object.
const calleeObj = loc.target.call.node.callee.object;
if (anim.global && calleeObj?.type === "Identifier") {
calleeObj.name = loc.parsed.timelineVar;
if (loc.target.call.node.arguments.length < 3) {
loc.target.call.node.arguments.push(parseExpr("0"));
}
}
loc.target.call.node.callee.property.name = "to";
removeVarsKey(varsArg, "immediateRender");
setVarsKey(varsArg, "duration", Math.max(0.001, setDuration));
}

return recast.print(loc.parsed.ast).code;
}

Expand Down
18 changes: 17 additions & 1 deletion packages/core/src/parsers/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,8 @@ export interface TweenCallInfo {
varsArg: any;
fromArg?: any;
positionArg?: any;
/** True for a base `gsap.set(...)` (off-timeline) rather than `tl.set(...)`. */
global?: boolean;
}

/** True when callee chain is rooted at the timeline variable. */
Expand Down Expand Up @@ -477,10 +479,22 @@ function findAllTweenCalls(
// Fire BEFORE children (pre-order) so chained outer calls come first.
if (node.type === "CallExpression") {
const callee = node.callee;
// A base `gsap.set("#sel", props)` is an off-timeline static hold — parse it as
// an editable global `set` so a static value round-trips and re-edits in place.
// STRING-LITERAL selectors only: variable-target holds stay surrounding source.
const gsapSetArg = node.arguments?.[0];
const isGlobalSet =
callee?.type === "MemberExpression" &&
callee.object?.type === "Identifier" &&
callee.object.name === "gsap" &&
callee.property?.type === "Identifier" &&
callee.property.name === "set" &&
(gsapSetArg?.type === "StringLiteral" ||
(gsapSetArg?.type === "Literal" && typeof gsapSetArg.value === "string"));
if (
callee?.type === "MemberExpression" &&
callee.property?.type === "Identifier" &&
isTimelineRootedCall(node, timelineVar) &&
(isTimelineRootedCall(node, timelineVar) || isGlobalSet) &&
GSAP_METHODS.has(callee.property.name)
) {
const method = callee.property.name;
Expand Down Expand Up @@ -509,6 +523,7 @@ function findAllTweenCalls(
selector: selectorValue,
varsArg: args[1],
positionArg: args[2],
...(isGlobalSet ? { global: true } : {}),
});
}
}
Expand Down Expand Up @@ -923,6 +938,7 @@ function tweenCallToAnimation(
group = classifyTweenPropertyGroup(kfProps);
}
if (group) anim.propertyGroup = group;
if (call.global) anim.global = true;
if (Object.keys(extras).length > 0) anim.extras = extras;
if (keyframesData) anim.keyframes = keyframesData;
if (motionPathResult) anim.arcPath = motionPathResult.arcPath;
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/parsers/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export interface GsapAnimation {
/** Which property group this tween belongs to (position, scale, size, rotation, visual, other).
* Undefined for legacy mixed tweens that bundle multiple groups. */
propertyGroup?: PropertyGroupName;
/** True for a base `gsap.set(...)` (a static hold that runs immediately, OFF the
* timeline) rather than `tl.set(...)`. Carries no timeline position and shows no
* keyframe marker — used to persist a static value (e.g. a 3D transform) without
* introducing a 0% keyframe. */
global?: boolean;
/** How this tween was constructed in source. Absent ⇒ literal. */
provenance?: GsapProvenance;
}
Expand Down Expand Up @@ -202,7 +207,10 @@ export function serializeGsapAnimations(
const posStr = typeof anim.position === "string" ? `"${anim.position}"` : anim.position;
switch (anim.method) {
case "set":
return ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`;
// A global set is a base `gsap.set` — off the timeline, no position arg.
return anim.global
? ` gsap.set(${selector}, ${propsStr});`
: ` ${timelineVar}.set(${selector}, ${propsStr}, ${posStr});`;
case "to":
return ` ${timelineVar}.to(${selector}, ${propsStr}, ${posStr});`;
case "from":
Expand Down Expand Up @@ -476,6 +484,12 @@ export function resolveConversionProps(
anim: GsapAnimation,
resolvedFromValues?: Record<string, number | string>,
): { fromProps: Record<string, number | string>; toProps: Record<string, number | string> } {
if (anim.method === "set") {
// A static hold becomes a keyframed `to` whose 0% and 100% both start at the
// set's value — the visual is unchanged until the user edits a keyframe to
// animate it. (The caller flips the call from `set` to `to` + adds a duration.)
return { fromProps: { ...anim.properties }, toProps: { ...anim.properties } };
}
if (anim.method === "to") {
const identity = buildIdentityMap(anim.properties);
const fromProps = resolvedFromValues ? { ...identity, ...resolvedFromValues } : identity;
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/parsers/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { describe, expect, it } from "vitest";
import {
addAnimationToScript,
addKeyframeToScript,
convertToKeyframesFromScript,
removeAnimationFromScript,
removeKeyframeFromScript,
updateAnimationInScript,
Expand Down Expand Up @@ -347,3 +348,22 @@ describe("T6c — keyframe write ops", () => {
expect(result).toBe(SCRIPT_D);
});
});

describe("T6c — convertToKeyframesFromScript: global gsap.set", () => {
const SCRIPT_GLOBAL_SET = `\
var tl = gsap.timeline({ paused: true });
gsap.set("#card", { rotationX: 50, rotationY: 20 });
window.__timelines["t"] = tl;`;

it("re-roots a global gsap.set onto the timeline (tl.to + position), not gsap.to", () => {
const animId = parseGsapScript(SCRIPT_GLOBAL_SET).animations[0].id;
const result = convertToKeyframesFromScript(SCRIPT_GLOBAL_SET, animId, undefined, 4);
// Off-timeline gsap.to would fire once at load and be unseekable; must be tl.to.
expect(result).toMatch(/tl\.to\(\s*"#card"/);
expect(result).not.toMatch(/gsap\.to\(/);
expect(result).toContain("keyframes:");
const reparsed = parseGsapScript(result).animations[0];
expect(reparsed.keyframes).toBeTruthy();
expect(reparsed.global).toBeFalsy();
});
});
Loading
Loading