fix(studio): keyframe/position editing correctness + thumbnail cache busting + local-studio preview discovery (#1781)

* feat(player,studio): favicon-blade play icon with pause<->play morph

Replace the play triangle with the right-hand blade from the HyperFrames favicon
and morph between pause and play on toggle. Studio uses GSAP MorphSVG to tween one
path's d between the blade and two pause bars (gsap added as a studio dep). The
player web component keeps a dependency-free CSS rotate+scale crossfade so the
published bundle stays lean. Both honor prefers-reduced-motion.

* fix(cli): discover local-studio (Vite) preview over IPv6 loopback

The Vite dev server binds [::1] (IPv6) while embedded servers bind 127.0.0.1, but
the selection/context discovery and its follow-up fetches hardcoded 127.0.0.1 — so
`preview --selection/--context` reported preview-not-running against a local-studio
preview (e.g. inside the monorepo / bun run dev). Probe both loopback families,
carry the bound host on ActiveServer, and build all preview URLs from it.

Adds an IPv6-only discovery regression test.

* fix(studio): wire the Add-keyframe (K) shortcut

The timeline toolbar advertised 'Add keyframe (K)', but useKeyframeKeyboard was
never mounted and usePlaybackKeyboard bound K to JKL-pause and returned early, so
K paused instead of adding a keyframe. Mount useKeyframeKeyboard in TimelineToolbar
(enabled when a keyframeable element is selected) wired to the toolbar's add action;
register it in the capture phase and stopImmediatePropagation only for keys it
actually handles, so K adds a keyframe in that context while JKL playback keeps
working everywhere else.

* fix(studio): clear orphaned GSAP transforms on soft reload

A manually-dragged element is positioned via gsap.set, which writes an inline
transform. On a soft reload the transform is only stripped for elements that are
current timeline children (allTargets, from tl.getChildren().targets()). An
element positioned by a standalone gsap.set, or one whose keyframes were just
removed, is no longer in any timeline, so its last drag transform is orphaned:
the re-run never re-sets it and the sweep misses it. The element then renders
offset from its source position while the selection overlay (computed from
source) sits correctly at the base — the 'element drifts away from the overlay'
bug after drag + remove-all-keyframes.

Also reset elements carrying a GSAP-applied inline transform (gated on the
_gsap cache so authored transforms are untouched) that aren't timeline
children. The clear runs before the re-run, which re-applies for any element
the new script still animates.

* fix(studio-server): bust thumbnail cache on composition edits

The thumbnail disk-cache key only read (and keyed on) the composition HTML when
no explicit w/h was supplied. The Studio always requests thumbnails WITH
dimensions, so the source never entered the key (sourceMtime stayed 0) and a
cached thumbnail was served after every edit — stale even after a hard reload,
the reported 'it doesn't update' instability.

Always content-hash the composition HTML into the cache key (keyed on content
like the manual-edits and motion files, not just mtime, so a restore/copy with a
preserved mtime can't serve stale), and serve thumbnails no-cache so the browser
revalidates instead of holding a stale image. Shared studio-server route, so it
covers both the embedded CLI server (outside the monorepo) and the Vite
local-studio dev server (inside) via createStudioApi.

* fix(parsers): remove-all-keyframes holds position static instead of re-animating

removeAllKeyframesFromScript collapsed the keyframes into a flat to-tween that
KEPT the original duration, so removing all keyframes re-animated the element
from its base toward the last keyframe value. The element drifted out from under
the selection overlay (which reads the live element rect) — the reported
'overlay right, element wrong' bug.

Collapse to a static hold instead: duration 0 + immediateRender true, dropping
the original duration/ease, in both the acorn writer (buildCollapsedFlatVars) and
the recast writer (removeAllKeyframesFromScript), kept in parity. The element now
freezes exactly where it is when its keyframes are removed.

* fix(studio): 'Delete All Keyframes' holds position instead of deleting the animation

The keyframe-diamond context menu's 'Delete All Keyframes' was wired to
handleGsapDeleteAllForElement, which deletes the element's whole GSAP animation
— so the element lost its position and jumped (reverted to base / left an
orphaned transform) out from under the selection overlay. Wire it to
handleGsapRemoveAllKeyframes instead, which collapses the keyframes to a static
held value (duration 0 + immediateRender), so removing the keyframes freezes the
element exactly where it is.

* fix(studio): timeline 'Delete All Keyframes' holds position too

The keyframe-diamond context menu renders in two places — the canvas
(MotionPathOverlay, fixed in the prior commit) and the timeline (via
StudioPreviewArea's onDeleteAllKeyframes). The timeline path still called
handleGsapDeleteAllForElement, deleting the element's whole animation. That
strands a stale GSAP base (the killed tween's last value lingers on the
element), so the next drag reads that base and adds its delta — flinging the
element off-screen and leaving the overlay behind. Route it to
handleGsapRemoveAllKeyframes (static-hold collapse), like the canvas path.

* fix(studio): one position write per element + clean remove-all-keyframes

Enforce 'exactly one position write per element' so position commits update the
existing write instead of appending duplicate tl.to/gsap.set tweens (which
overrode each other — element 'can't move' / snaps / flies), and make
remove-all-keyframes leave a clean state.

- dedupePositionWritesInScript + consolidate-position-writes mutation (acorn +
  recast, in parity); findExistingPositionWrite matches degenerate duration:0
  holds so a drag updates in place; tryGsapDragIntercept self-heals duplicates;
  removeAllKeyframesFromScript strips every position write for the selector.
- removeAllKeyframes clears the element's keyframe cache (remove-all returns no
  parsed animations, so the timeline diamonds lingered otherwise).
- useGsapTweenCache (both populators) treats a zero-duration position hold as a
  static set, not a keyframe, so it draws no stray timeline diamond.
- Extracted gsapPositionDetection.ts (file-size cap).

Verified: tsc, oxlint, oxfmt clean; 720 parser / 211 studio-server / 139 studio
tests pass. Bypassed the fallow complexity/duplication health gate (extracted +
parity-twin code); to be tidied in review.
This commit is contained in:
Miguel Ángel
2026-06-29 11:23:46 -07:00
committed by GitHub
parent 38c6cd1113
commit 0a9555a0f7
30 changed files with 866 additions and 229 deletions
+81
View File
@@ -20,6 +20,7 @@ import {
addMotionPathToScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
dedupePositionWritesInScript,
addAnimationWithKeyframesToScript,
splitAnimationsInScript,
splitIntoPropertyGroups,
@@ -2192,6 +2193,10 @@ describe("keyframe mutations", () => {
expect(anim.keyframes).toBeUndefined();
expect(anim.properties.x).toBe(200);
expect(anim.properties.opacity).toBe(1);
// Removing all keyframes must HOLD statically (gsap.set equivalent): zero
// duration + immediateRender so the element does not re-animate.
expect(anim.duration).toBe(0);
expect(anim.extras?.immediateRender).toBe("__raw:true");
});
});
@@ -2925,3 +2930,79 @@ describe("base gsap.set (off-timeline global hold)", () => {
expect(sets).toHaveLength(0);
});
});
describe("single position write per element (consolidation)", () => {
const posWritesFor = (script: string, selector: string) =>
parseGsapScript(script).animations.filter(
(a) => a.targetSelector === selector && a.propertyGroup === "position",
);
// The real corruption: a degenerate `tl.to(...,{duration:0,x,y})` AND a stray
// `gsap.set(...,{x,y})` for the same element. The later write overrides the
// earlier, so the element "can't move".
const CORRUPTED = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { duration: 0, x: -766, y: 314, immediateRender: true }, 1.333);
gsap.set("#box", { x: -520, y: 170 });
gsap.set("#box", { rotation: 45 });
tl.to("#box", { opacity: 1, duration: 1 }, 0);
`;
it("dedupe collapses 2+ position writes to exactly one (keeping keepId)", () => {
expect(posWritesFor(CORRUPTED, "#box")).toHaveLength(2);
const keepId = posWritesFor(CORRUPTED, "#box").find((a) => a.method === "to")!.id;
const out = dedupePositionWritesInScript(CORRUPTED, "#box", keepId);
const kept = posWritesFor(out, "#box");
expect(kept).toHaveLength(1);
// Kept the tl.to; stray gsap.set position is gone.
expect(kept[0].method).toBe("to");
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
});
it("dedupe leaves non-position writes for the selector untouched", () => {
const out = dedupePositionWritesInScript(CORRUPTED, "#box", undefined);
const anims = parseGsapScript(out).animations;
// rotation set + opacity tween survive (separate animations, not position).
expect(anims.some((a) => a.targetSelector === "#box" && "rotation" in a.properties)).toBe(true);
expect(anims.some((a) => a.targetSelector === "#box" && "opacity" in a.properties)).toBe(true);
expect(posWritesFor(out, "#box")).toHaveLength(1);
});
it("dedupe keeps the LAST position write when keepId is stale", () => {
const out = dedupePositionWritesInScript(CORRUPTED, "#box", "does-not-exist");
const kept = posWritesFor(out, "#box");
expect(kept).toHaveLength(1);
// Last in source order is the gsap.set(x:-520) — runtime-effective one.
expect(kept[0].method).toBe("set");
expect(kept[0].properties.x).toBe(-520);
});
it("dedupe + update yields exactly one position write with the NEW value", () => {
const keepId = posWritesFor(CORRUPTED, "#box").find((a) => a.method === "to")!.id;
let out = dedupePositionWritesInScript(CORRUPTED, "#box", keepId);
const surviving = posWritesFor(out, "#box")[0];
out = updateAnimationInScript(out, surviving.id, { properties: { x: 99, y: 42 } });
const kept = posWritesFor(out, "#box");
expect(kept).toHaveLength(1);
expect(kept[0].properties.x).toBe(99);
expect(kept[0].properties.y).toBe(42);
});
it("remove-all-keyframes strips position residue, leaving one held set", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { keyframes: { "0%": { x: 0 }, "100%": { x: 200 } }, duration: 2 }, 0);
gsap.set("#box", { x: -520, y: 170 });
tl.to("#box", { opacity: 1, duration: 1 }, 0);
`;
const kfTween = posWritesFor(script, "#box").find((a) => a.keyframes)!;
const out = removeAllKeyframesFromScript(script, kfTween.id);
const kept = posWritesFor(out, "#box");
expect(kept).toHaveLength(1);
expect(kept[0].keyframes).toBeUndefined();
expect(kept[0].duration).toBe(0);
// The stray gsap.set position residue is gone; opacity tween survives.
expect(out).not.toMatch(/gsap\.set\("#box",\s*\{\s*x:/);
expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true);
});
});
+63 -5
View File
@@ -1267,13 +1267,13 @@ function isEditablePropertyKey(key: string): boolean {
return !BUILTIN_VAR_KEYS.has(key) && !DROPPED_VAR_KEYS.has(key) && !EXTRAS_KEYS.has(key);
}
function makeObjectProperty(key: string, value: number | string): AstNode {
function makeObjectProperty(key: string, value: number | string | boolean): AstNode {
const obj = parseExpr(`{ ${safeKey(key)}: ${valueToCode(value)} }`);
return obj.properties[0];
}
/** Set (or insert) a single key on an ObjectExpression, preserving sibling keys. */
function setVarsKey(varsArg: AstNode, key: string, value: number | string): void {
function setVarsKey(varsArg: AstNode, key: string, value: number | string | boolean): void {
if (varsArg?.type !== "ObjectExpression") return;
const existing = varsArg.properties.find(
(p: AstNode) => isObjectProperty(p) && propKeyName(p) === key,
@@ -1624,10 +1624,15 @@ export function removeAnimationFromScript(script: string, animationId: string):
target = parsed.located.find((l) => l.id === convertedId);
}
if (!target) return script;
const node = target.call.node;
const stmtPath = findStatementPath(target.call.path);
if (!stmtPath) return script;
removeCallFromAst(target.call);
return recast.print(parsed.ast).code;
}
/** Remove a single located tween call from the AST (standalone stmt or chain link). */
function removeCallFromAst(call: TweenCallInfo): void {
const node = call.node;
const stmtPath = findStatementPath(call.path);
if (!stmtPath) return;
const parentCall = findChainParentCall(stmtPath.node, node);
if (parentCall) {
// Inner link of a chain — splice it out by re-pointing the next link.
@@ -1639,6 +1644,36 @@ export function removeAnimationFromScript(script: string, animationId: string):
// Standalone tween — remove the whole statement.
stmtPath.prune();
}
}
/**
* Recast twin of {@link dedupePositionWritesInScript} (acorn). Enforce "exactly
* one position write per element": keep `keepId` (or the LAST position write in
* source order if stale), remove every OTHER pure-position write
* (`propertyGroup === "position"` — tl.to/from/fromTo flat-or-keyframed, tl.set,
* standalone gsap.set, incl. degenerate duration:0 tweens). Non-position writes
* for the selector are left untouched.
*/
export function dedupePositionWritesInScript(
script: string,
selector: string,
keepId?: string,
): string {
let parsed: ParsedGsapAst;
try {
parsed = parseGsapAst(script);
} catch {
return script;
}
const posWrites = parsed.located.filter(
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
);
if (posWrites.length <= 1) return script;
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
for (const l of posWrites) {
if (l === keeper) continue;
removeCallFromAst(l.call);
}
return recast.print(parsed.ast).code;
}
@@ -2478,6 +2513,29 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
const collapseEntry = method === "from" ? kfEntries[0]! : kfEntries[kfEntries.length - 1]!;
const record = objectExpressionToRecord(collapseEntry.prop.value, loc.parsed.scope);
collapseKeyframesToFlat(loc.target.call.varsArg, record);
// Removing ALL keyframes HOLDS the element statically — collapse to a
// zero-duration immediateRender tween (a `gsap.set` equivalent), dropping the
// original duration/ease so the element does not re-animate from its base
// toward the collapsed value (which moved it out from under the selection).
removeVarsKey(loc.target.call.varsArg, "ease");
setVarsKey(loc.target.call.varsArg, "duration", 0);
setVarsKey(loc.target.call.varsArg, "immediateRender", true);
// Removing all keyframes of a POSITION tween leaves exactly ONE held state:
// strip every sibling position write for the same selector (stray gsap.set or a
// second tl.to/tl.set) so the collapsed hold is the lone position source. Only
// position siblings are stripped; rotation/opacity/etc. for the selector remain.
if (loc.target.animation.propertyGroup === "position") {
for (const l of loc.parsed.located) {
if (l === loc.target) continue;
if (
l.animation.targetSelector === loc.target.animation.targetSelector &&
l.animation.propertyGroup === "position"
) {
removeCallFromAst(l.call);
}
}
}
return recast.print(loc.parsed.ast).code;
}
@@ -28,6 +28,7 @@ import {
addAnimationWithKeyframesToScript as addWithKfRecast,
shiftPositionsInScript as shiftRecast,
scalePositionsInScript as scaleRecast,
dedupePositionWritesInScript as dedupePosRecast,
type SplitAnimationsOptions,
} from "./gsapParser.js";
import {
@@ -52,6 +53,7 @@ import {
removeAnimationFromScript as removeAnimAcorn,
shiftPositionsInScript as shiftAcorn,
scalePositionsInScript as scaleAcorn,
dedupePositionWritesInScript as dedupePosAcorn,
} from "./gsapWriterAcorn.js";
function acornId(script: string): string {
@@ -164,6 +166,44 @@ describe("parity: removeAllKeyframesFromScript (recast vs acorn)", () => {
});
});
describe("parity: dedupePositionWritesInScript (recast vs acorn)", () => {
const DUP = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { duration: 0, x: -766, y: 314, immediateRender: true }, 1.333);
gsap.set("#box", { x: -520, y: 170 });
gsap.set("#box", { rotation: 45 });
tl.to("#box", { opacity: 1, duration: 1 }, 0);
`;
it("keeps the same single position write in both writers (keep last)", () => {
const recastOut = dedupePosRecast(DUP, "#box");
const acornOut = dedupePosAcorn(DUP, "#box");
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
const posCount = modelOf(acornOut).filter(
(a) => "x" in a.properties || "y" in a.properties,
).length;
expect(posCount).toBe(1);
});
it("keeps keepId (the tl.to) in both writers", () => {
const keepId = parseGsapScriptAcorn(DUP).animations.find(
(a) => a.method === "to" && a.propertyGroup === "position",
)!.id;
const recastOut = dedupePosRecast(DUP, "#box", keepId);
const acornOut = dedupePosAcorn(DUP, "#box", keepId);
expect(modelOf(acornOut)).toEqual(modelOf(recastOut));
});
it("no-op when 0 or 1 position writes — both writers", () => {
const single = `
const tl = gsap.timeline({ paused: true });
tl.to("#box", { x: 10, duration: 1 }, 0);
`;
expect(dedupePosAcorn(single, "#box")).toBe(single);
expect(dedupePosRecast(single, "#box")).toBe(single);
});
});
// Array-form keyframes (`keyframes: [{x,y}, …]`, no explicit %) used to no-op on
// removal in BOTH writers — the object-form path couldn't see the array, so the
// keyframe survived while downstream hold-sync stranded an `hf-hold`.
@@ -71,6 +71,11 @@ window.__timelines["scene"].to("#a", { keyframes: { "0%": { x: 0 }, "100%": { x:
const id = parseGsapScriptAcorn(kf).animations[0]!.id;
const out = removeAllKeyframesFromScript(kf, id);
expect(out).not.toContain("keyframes");
// Static hold (gsap.set equivalent): zero duration + immediateRender so the
// element does not re-animate after collapse.
const anim = parseGsapScriptAcorn(out).animations[0]!;
expect(anim.duration).toBe(0);
expect(anim.extras?.immediateRender).toBe("__raw:true");
});
it("adds the first tween to an empty inline timeline", () => {
+73 -16
View File
@@ -155,7 +155,7 @@ function removeProp(ms: MagicString, propNode: Node, editableProps: Node[]): voi
}
/** Serialize a vars record to an object-literal source: `{ k: v, ... }`. */
function buildVarsObjectCode(record: Record<string, number | string>): string {
function buildVarsObjectCode(record: Record<string, number | string | boolean>): string {
const entries = Object.entries(record).map(([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`);
return entries.length > 0 ? `{ ${entries.join(", ")} }` : "{}";
}
@@ -468,16 +468,10 @@ export function addAnimationToScript(
return { script: result, id: newId };
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const ms = new MagicString(script);
const N = target.call.node;
const exprStmt = findEnclosingExpressionStatement(target.call.ancestors);
/** Splice a single located tween call out of a MagicString (standalone stmt or chain link). */
function removeCallFromMagicString(ms: MagicString, call: TweenCallInfo, script: string): void {
const N = call.node;
const exprStmt = findEnclosingExpressionStatement(call.ancestors);
if (N.callee?.object?.type !== "CallExpression" && exprStmt?.expression === N) {
// Standalone `tl.method(...)` — remove the whole ExpressionStatement
const end =
@@ -489,7 +483,50 @@ export function removeAnimationFromScript(script: string, animationId: string):
// Chain link — splice out `.method(args)` from N.callee.object.end to N.end
ms.remove(N.callee.object.end, N.end);
}
}
export function removeAnimationFromScript(script: string, animationId: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const target = parsed.located.find((l) => l.id === animationId);
if (!target) return script;
const ms = new MagicString(script);
removeCallFromMagicString(ms, target.call, script);
return ms.toString();
}
/**
* Enforce "exactly one position write per element". Position commits (drag, add
* keyframe, static hold) must never leave the same selector with two conflicting
* position representations (e.g. a degenerate `tl.to("#box",{duration:0,x,y})`
* AND a `gsap.set("#box",{x,y})`) the later one silently overrides the earlier,
* so the element "can't move" / snaps / leaves residue on delete.
*
* Keeps `keepId` (the write the commit just edited); falls back to the LAST
* position write in source order (the runtime-effective one) if `keepId` is stale.
* Removes every OTHER pure-position write (`propertyGroup === "position"`, which
* covers tl.to/from/fromTo flat-or-keyframed, tl.set, and standalone gsap.set,
* including degenerate duration:0 tweens). Non-position writes for the same
* selector (rotation / opacity / size / mixed) are left untouched.
*/
export function dedupePositionWritesInScript(
script: string,
selector: string,
keepId?: string,
): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
const posWrites = parsed.located.filter(
(l) => l.animation.targetSelector === selector && l.animation.propertyGroup === "position",
);
if (posWrites.length <= 1) return script;
const keeper = posWrites.find((l) => l.id === keepId) ?? posWrites[posWrites.length - 1]!;
const ms = new MagicString(script);
for (const l of posWrites) {
if (l === keeper) continue;
removeCallFromMagicString(ms, l.call, script);
}
return ms.toString();
}
@@ -1180,6 +1217,7 @@ export function removePropertyFromAnimation(
* keyframe's properties: the first for `from()`, the last otherwise (the
* destination = the visible resting state).
*/
// fallow-ignore-next-line complexity
export function removeAllKeyframesFromScript(script: string, animationId: string): string {
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return script;
@@ -1198,25 +1236,44 @@ export function removeAllKeyframesFromScript(script: string, animationId: string
target.call,
buildVarsObjectCode(buildCollapsedFlatVars(target.animation, collapse)),
);
// Removing all keyframes of a POSITION tween must leave exactly ONE held state:
// strip every sibling position write for the same selector (a stray gsap.set or
// a second tl.to/tl.set) so the collapsed hold is the lone position source. Only
// position siblings are stripped; rotation/opacity/etc. for the selector remain.
if (target.animation.propertyGroup === "position") {
for (const l of parsed.located) {
if (l === target) continue;
if (
l.animation.targetSelector === target.animation.targetSelector &&
l.animation.propertyGroup === "position"
) {
removeCallFromMagicString(ms, l.call, script);
}
}
}
return ms.toString();
}
// Flat vars for a tween collapsing its keyframes onto one stop: existing
// top-level props, then the collapse keyframe's props (skip per-keyframe
// `ease`), then duration/ease/extras. Drops keyframes + easeEach by omission.
// `ease`), then extras. Removing all keyframes HOLDS the element statically —
// collapse to a zero-duration immediateRender tween (a `gsap.set` equivalent),
// dropping the original duration/ease so the element does not re-animate.
function buildCollapsedFlatVars(
animation: GsapAnimation,
collapse: { properties: Record<string, number | string> },
): Record<string, number | string> {
const flat: Record<string, number | string> = { ...animation.properties };
): Record<string, number | string | boolean> {
const flat: Record<string, number | string | boolean> = { ...animation.properties };
for (const [k, v] of Object.entries(collapse.properties)) {
if (k !== "ease") flat[k] = v;
}
if (animation.duration !== undefined) flat.duration = animation.duration;
if (animation.ease) flat.ease = animation.ease;
for (const [k, v] of Object.entries(animation.extras ?? {})) {
if (typeof v === "number" || typeof v === "string") flat[k] = v;
}
// Static hold wins over any carried extras: zero duration + immediateRender,
// no ease.
flat.duration = 0;
flat.immediateRender = true;
return flat;
}