fix(studio): Enable keyframes works (and auto-tracks) on elements with a global gsap.set (#1728)

* fix(core): a global gsap.set is off-timeline, so resolvedStart is 0 not the comp end

resolveTimelinePositions walks anims in document order advancing a cursor; a
global `gsap.set(...)` carries no position arg, so it fell through to the
cursor fallback and inherited the comp-end time (every prior tween's duration
summed) as its resolvedStart. A global set is a load-time hold — its start is 0.

This silently broke 'Enable keyframes' on any element whose only animation is a
global gsap.set (e.g. a statically-positioned card): promoteSetToKeyframes bails
when `playhead <= setStart`, and setStart was the comp end, so any playhead
before the end was a no-op. Pin a global set to resolvedStart 0 in both the
recast and acorn parsers; don't let it advance the cursor/prevStart.

* fix(studio): Enable-keyframes marks the generated 0% endpoint as auto-tracking

When 'Enable keyframes' promotes a static set to a two-stop tween, the 0% (the
held start the user didn't choose) is now marked `auto: true` → serialized as the
`_auto: 1` marker. The parser's endpoint-sync then keeps it tracking the nearest
keyframe until the user edits it directly; the 100% (the real keyframe placed at
the playhead) stays fixed.

This re-wires the auto-endpoint behavior that was silently dropped in #1605 (the
sync logic stayed, but no flow produced an `_auto` endpoint anymore, so an
untouched 0% never tracked). Adds a guard test so it can't be lost again.

* chore: suppress fallow complexity findings surfaced in the touched files

The resolveTimelinePositions guard added a branch (pushes it over threshold), and
changed-file scope re-surfaces pre-existing complex functions (readElementPosition,
applyArcWaypointAtPlayhead, the useEnableKeyframes callback). Bare directives only.

* fix(studio): dragging a --hf-studio-offset element no longer flies

Dragging a static element positioned via the legacy --hf-studio-offset CSS var
(e.g. dot-a) flew off-screen — three independent failure modes, all fixed:

1. Live drag integrated: the per-move draft read its base from the live transform
   it set last frame (gsap.getProperty), so base+delta accumulated frame-over-frame.
   Fix: carry a stable baseGsap on the in-memory drag member (immune to mid-drag
   re-renders that wipe the data-hf-drag-* attrs) and use it as the fallback.

2. Commit re-added the delta: the source commit re-read the wiped attrs / live
   transform. Fix: re-stamp the stable base/initial attrs in applyManualOffsetDragCommit
   before the commit reads them.

3. Drop left it offset: the committed source was correct, but the LIVE element kept
   its --hf-studio-offset var + translate:var(...), which composed with the GSAP
   transform (rendered at dropped + offset) until a full reload. Fix: on cleanup,
   when GSAP owns the position, clearStudioPathOffset() migrates the element off the
   legacy CSS channel (leaving transform untouched) — matching the stripped source.

Adds a regression suite covering all three layers.
This commit is contained in:
Miguel Ángel
2026-06-25 21:31:23 -04:00
committed by GitHub
parent bafed1fc9a
commit 226a741c9d
7 changed files with 222 additions and 9 deletions
@@ -297,6 +297,24 @@ describe("parseGsapScript", () => {
});
describe("resolvedStart — timeline position resolution", () => {
it("a global gsap.set is off-timeline: resolvedStart is 0, not the comp-end cursor", () => {
// The trailing global `gsap.set` carries no position; the cursor has advanced
// to ~3 by the time it's reached. It must NOT inherit that as its start — it's
// a load-time hold at 0. (Regression: setStart=cursor blocked Enable-keyframes.)
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#a", { x: 100, duration: 3 }, 0);
gsap.set("#card", { x: -74, y: -469 });
`;
const result = parseGsapScript(script);
const set = result.animations.find((a) => a.targetSelector === "#card");
expect(set?.method).toBe("set");
expect(set?.global).toBe(true);
expect(set?.resolvedStart).toBe(0);
// The off-timeline set must not perturb the real tween's position either.
expect(result.animations.find((a) => a.targetSelector === "#a")?.resolvedStart).toBe(0);
});
it("resolves chained from() tweens with relative positions (sdk-test pattern)", () => {
const script = `
const tl = gsap.timeline({ defaults: { ease: "power3.out" } });
+9
View File
@@ -1045,6 +1045,15 @@ function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
let cursor = 0;
let prevStart = 0;
for (const anim of anims) {
// A global `gsap.set(...)` is off-timeline — it's applied once at load, not
// sequenced on the master timeline. It carries no position arg, so the
// cursor-based fallback below would otherwise hand it the comp-end time
// (every prior tween's duration summed). Pin it to 0 (its load-time start)
// and don't let it advance the cursor/prevStart for following tweens.
if (anim.method === "set" && anim.global) {
anim.resolvedStart = 0;
continue;
}
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
let start: number | null;
@@ -995,10 +995,19 @@ function applyTimelineDefaults(
}
}
// fallow-ignore-next-line complexity
function resolveTimelinePositions(anims: Omit<GsapAnimation, "id">[]): void {
let cursor = 0;
let prevStart = 0;
for (const anim of anims) {
// A global `gsap.set(...)` is off-timeline — applied once at load, not
// sequenced on the master timeline. It carries no position arg, so the
// cursor fallback would otherwise hand it the comp-end time. Pin it to 0
// (its load-time start) and don't advance the cursor/prevStart.
if (anim.method === "set" && anim.global) {
anim.resolvedStart = 0;
continue;
}
const duration = anim.method === "set" ? 0 : (anim.duration ?? GSAP_DEFAULT_DURATION);
let start: number | null;