feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes (#1499)

* feat(sdk,core): ws-3 prerequisites — acorn keyframe-collapse foundation + removeAllKeyframes

P1: gsapWriter.parity.test.ts — recast-vs-acorn parity harness (reparse-equivalence).
P2: move pure keyframe-conversion transforms (resolveConversionProps, cssIdentityValue)
    to recast-free gsapSerialize.ts so the acorn/SDK path can share them.
P3: MagicString splice primitives in gsapWriterAcorn.ts (buildVarsObjectCode, overwriteVarsArg).
P4: reference vertical slice — removeAllKeyframesFromScript ported to acorn writer +
    removeAllKeyframes SDK op (types/mutate/can) + Studio cutover (useGsapKeyframeOps),
    replacing the server-authoritative ponytail stub.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sdk,core): ws-3 — convertToKeyframes acorn port + SDK op + Studio cutover

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,core): ws-3 — materializeKeyframes + splitIntoPropertyGroups acorn ports + SDK ops

- acorn: buildKeyframeObjectCode, materializeKeyframesFromScript, addAnimationWithKeyframesToScript
- acorn: splitIntoPropertyGroupsFromScript with filterGroupKeyframes/filterGroupProperties helpers
- parity tests: materialize (2 positive + 1 no-op) and split (2 positive + 2 no-op) suites
- SDK types: materializeKeyframes + splitIntoPropertyGroups EditOp variants
- mutate.ts: handlers + can() gates for both new ops
- mutate.gsap.test.ts: 6 new tests (53 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

* feat(sdk,core): ws-3 — splitAnimationsInScript acorn port + SDK op

- acorn: updateAnimationSelectorInScript, insertInheritedStateSetInScript helpers
- acorn: splitAnimationsInScript exported (parity with recast version)
- parity: 4 new fixtures (3 cases + no-op) — 23 total parity tests
- SDK types: splitAnimations EditOp variant
- mutate.ts: handleSplitAnimations + can() gate
- mutate.gsap.test.ts: 3 new tests (56 total passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:51:28 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Miguel Ángel
parent ceb815c318
commit a746db6017
9 changed files with 1397 additions and 75 deletions
+214
View File
@@ -510,6 +510,220 @@ describe("removeGsapKeyframe", () => {
});
});
describe("removeAllKeyframes", () => {
it("collapses keyframed to() tween to last keyframe's props", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).not.toContain("keyframes");
expect(newScript).not.toContain('"50%"');
expect(newScript).toContain("opacity: 1");
});
it("no-op (empty patch) when animation id not found", () => {
const parsed = fresh(KF_SCRIPT);
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: "nope" });
expect(result.forward).toHaveLength(0);
});
it("no-op when tween has no keyframes", () => {
const parsed = fresh(GSAP_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, { type: "removeAllKeyframes", animationId: animId });
expect(result.forward).toHaveLength(0);
});
});
// ─── convertToKeyframes ────────────────────────────────────────────────────────
describe("convertToKeyframes", () => {
// GSAP_SCRIPT: position 0.2 → id suffix "200"; opacity = visual group
it("converts flat to() tween to percentage keyframes", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: TWEEN_ANIM_ID });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("keyframes");
expect(newScript).toContain('"0%"');
expect(newScript).toContain('"100%"');
expect(newScript).toContain("easeEach");
expect(newScript).toContain('ease: "none"');
});
it("passes resolvedFromValues into 0% endpoint", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 200, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(script);
// position 0 → "0"; x = position group
const animId = `[data-hf-id="hf-box"]-to-0-position`;
const result = applyOp(parsed, {
type: "convertToKeyframes",
animationId: animId,
resolvedFromValues: { x: 42 },
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("42");
});
it("no-op when animation already has keyframes", () => {
const parsed = fresh(KF_SCRIPT);
const animId = `[data-hf-id="hf-box"]-to-0-visual`;
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: animId });
expect(result.forward).toHaveLength(0);
});
it("no-op when animation id not found", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "convertToKeyframes", animationId: "nope" });
expect(result.forward).toHaveLength(0);
});
});
// ─── materializeKeyframes ─────────────────────────────────────────────────────
describe("materializeKeyframes", () => {
it("adds keyframes property to flat tween", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: TWEEN_ANIM_ID,
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("keyframes");
expect(newScript).toContain('"0%"');
expect(newScript).toContain('"100%"');
});
it("injects easeEach into keyframes object", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: TWEEN_ANIM_ID,
keyframes: [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
],
easeEach: "power2.out",
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("easeEach");
expect(newScript).toContain("power2.out");
});
it("no-op when animation id not found", () => {
const parsed = fresh();
const result = applyOp(parsed, {
type: "materializeKeyframes",
animationId: "nope",
keyframes: [{ percentage: 0, properties: { opacity: 0 } }],
});
expect(result.forward).toHaveLength(0);
});
});
// ─── splitIntoPropertyGroups ──────────────────────────────────────────────────
describe("splitIntoPropertyGroups", () => {
it("splits mixed tween into multiple group tweens", () => {
const script = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, opacity: 0.5, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(script);
// mixed tween has no propertyGroup → no group suffix in id
const animId = `[data-hf-id="hf-box"]-to-0`;
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
// x is position group, opacity is visual group — expect 2 tweens
const toCount = (newScript.match(/\.to\(/g) ?? []).length;
expect(toCount).toBe(2);
});
it("no-op when animation id not found", () => {
const parsed = fresh();
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: "nope" });
expect(result.forward).toHaveLength(0);
});
it("no-op when tween has only one property group", () => {
// x + y = same "position" group → nothing to split
const script = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, y: 50, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const parsed = fresh(script);
const animId = `[data-hf-id="hf-box"]-to-0-position`;
const result = applyOp(parsed, { type: "splitIntoPropertyGroups", animationId: animId });
expect(result.forward).toHaveLength(0);
});
});
// ─── splitAnimations ──────────────────────────────────────────────────────────
describe("splitAnimations", () => {
const SPLIT_SCRIPT = `var tl = gsap.timeline({ paused: true });
tl.to("#hero", { x: 200, duration: 4 }, 0);
window.__timelines["t"] = tl;`;
function freshSplit() {
return parseMutable(`<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-hero"></div>
<script>${SPLIT_SCRIPT}</script>
</div>`);
}
it("retargets post-split tween to newId", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "hero",
newId: "hero-2",
splitTime: 3,
elementStart: 0,
elementDuration: 4,
});
expect(result.forward).toHaveLength(1);
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain("#hero-2");
});
it("spanning tween produces fromTo on new element", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "hero",
newId: "hero-2",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
});
const newScript = String(result.forward[0]?.value ?? "");
expect(newScript).toContain(".fromTo(");
expect(newScript).toContain("#hero-2");
});
it("no-op when originalId not found", () => {
const parsed = freshSplit();
const result = applyOp(parsed, {
type: "splitAnimations",
originalId: "nonexistent",
newId: "x",
splitTime: 2,
elementStart: 0,
elementDuration: 4,
});
expect(result.forward).toHaveLength(0);
});
});
// ─── Label ops ────────────────────────────────────────────────────────────────
describe("addLabel", () => {
+123 -11
View File
@@ -50,6 +50,11 @@ import {
removePropertyFromAnimation,
addKeyframeToScript,
removeKeyframeFromScript,
removeAllKeyframesFromScript,
convertToKeyframesFromScript,
materializeKeyframesFromScript,
splitIntoPropertyGroupsFromScript,
splitAnimationsInScript,
updateKeyframeInScript,
addLabelToScript,
removeLabelFromScript,
@@ -146,18 +151,8 @@ function dispatchRemoveGsapKeyframe(
: handleRemoveGsapKeyframe(parsed, op.animationId, op.keyframeIndex);
}
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
function applyGsapKeyframeOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
switch (op.type) {
case "addGsapTween":
return handleAddGsapTween(parsed, op.target, op.tween);
case "setGsapTween":
return handleSetGsapTween(parsed, op.animationId, op.properties);
case "removeGsapProperty":
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
case "removeGsapTween":
return handleRemoveGsapTween(parsed, op.animationId);
case "deleteAllForSelector":
return handleDeleteAllForSelector(parsed, op.selector);
case "setGsapKeyframe":
return handleSetGsapKeyframe(
parsed,
@@ -171,6 +166,41 @@ function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undef
return handleAddGsapKeyframe(parsed, op.animationId, op.position, op.value);
case "removeGsapKeyframe":
return dispatchRemoveGsapKeyframe(parsed, op);
case "removeAllKeyframes":
return handleRemoveAllKeyframes(parsed, op.animationId);
case "convertToKeyframes":
return handleConvertToKeyframes(parsed, op.animationId, op.resolvedFromValues);
case "materializeKeyframes":
return handleMaterializeKeyframes(
parsed,
op.animationId,
op.keyframes,
op.easeEach,
op.resolvedSelector,
);
case "splitIntoPropertyGroups":
return handleSplitIntoPropertyGroups(parsed, op.animationId);
case "splitAnimations":
return handleSplitAnimations(parsed, op);
default:
return undefined;
}
}
function applyGsapOp(parsed: ParsedDocument, op: EditOp): MutationResult | undefined {
const kf = applyGsapKeyframeOp(parsed, op);
if (kf !== undefined) return kf;
switch (op.type) {
case "addGsapTween":
return handleAddGsapTween(parsed, op.target, op.tween);
case "setGsapTween":
return handleSetGsapTween(parsed, op.animationId, op.properties);
case "removeGsapProperty":
return handleRemoveGsapProperty(parsed, op.animationId, op.property, op.from);
case "removeGsapTween":
return handleRemoveGsapTween(parsed, op.animationId);
case "deleteAllForSelector":
return handleDeleteAllForSelector(parsed, op.selector);
default:
return undefined;
}
@@ -737,6 +767,83 @@ function handleRemoveGsapTween(parsed: ParsedDocument, animationId: string): Mut
return gsapScriptChange(script, newScript);
}
function handleRemoveAllKeyframes(parsed: ParsedDocument, animationId: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = removeAllKeyframesFromScript(script, animationId);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleConvertToKeyframes(
parsed: ParsedDocument,
animationId: string,
resolvedFromValues?: Record<string, number | string>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = convertToKeyframesFromScript(script, animationId, resolvedFromValues);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleMaterializeKeyframes(
parsed: ParsedDocument,
animationId: string,
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>,
easeEach?: string,
resolvedSelector?: string,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const newScript = materializeKeyframesFromScript(
script,
animationId,
keyframes,
easeEach,
resolvedSelector,
);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleSplitIntoPropertyGroups(
parsed: ParsedDocument,
animationId: string,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const { script: newScript } = splitIntoPropertyGroupsFromScript(script, animationId);
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleSplitAnimations(
parsed: ParsedDocument,
op: Extract<EditOp, { type: "splitAnimations" }>,
): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
const { script: newScript } = splitAnimationsInScript(script, {
originalId: op.originalId,
newId: op.newId,
splitTime: op.splitTime,
elementStart: op.elementStart,
elementDuration: op.elementDuration,
});
if (newScript === script) return EMPTY;
setGsapScript(parsed.document, newScript);
return gsapScriptChange(script, newScript);
}
function handleDeleteAllForSelector(parsed: ParsedDocument, selector: string): MutationResult {
const script = getGsapScript(parsed.document);
if (!script) return EMPTY;
@@ -954,6 +1061,11 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
case "removeGsapKeyframe":
case "removeGsapProperty":
case "removeGsapTween":
case "removeAllKeyframes":
case "convertToKeyframes":
case "materializeKeyframes":
case "splitIntoPropertyGroups":
case "splitAnimations":
case "deleteAllForSelector":
case "removeLabel":
if (getGsapScript(parsed.document) === null)
+26
View File
@@ -105,7 +105,33 @@ export type EditOp =
| { type: "removeGsapKeyframe"; animationId: string; percentage: number }
| { type: "removeGsapProperty"; animationId: string; property: string; from?: boolean }
| { type: "removeGsapTween"; animationId: string }
| { type: "removeAllKeyframes"; animationId: string }
| {
type: "convertToKeyframes";
animationId: string;
resolvedFromValues?: Record<string, number | string>;
}
| { type: "deleteAllForSelector"; selector: string }
| {
type: "materializeKeyframes";
animationId: string;
keyframes: Array<{
percentage: number;
properties: Record<string, number | string>;
ease?: string;
}>;
easeEach?: string;
resolvedSelector?: string;
}
| { type: "splitIntoPropertyGroups"; animationId: string }
| {
type: "splitAnimations";
originalId: string;
newId: string;
splitTime: number;
elementStart: number;
elementDuration: number;
}
| { type: "addLabel"; name: string; position: number }
| { type: "removeLabel"; name: string };