From 565185c672b524234586c3fb94f85c7011e26009 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Tue, 16 Jun 2026 03:06:03 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(sdk,core):=20ws-3=20=E2=80=94=20unroll?= =?UTF-8?q?DynamicAnimations=20acorn=20port=20+=20SDK=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 Co-authored-by: Miguel Ángel --- .../src/parsers/gsapWriter.parity.test.ts | 69 +++++++++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 85 +++++++++++++++++++ packages/sdk/src/engine/mutate.ts | 8 ++ packages/sdk/src/types.ts | 11 ++- 4 files changed, 172 insertions(+), 1 deletion(-) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index e8f8bed99..e1b9fa652 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -27,6 +27,7 @@ import { setArcPathInScript as setArcAcorn, updateArcSegmentInScript as updateArcSegmentAcorn, removeArcPathFromScript as removeArcAcorn, + unrollDynamicAnimations as unrollAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; @@ -460,3 +461,71 @@ describe("removeArcPathFromScript: acorn output correctness", () => { expect(arcShapeOf(removeArcAcorn(enabled, id)).arcPath).toBeUndefined(); }); }); + +// ─── unrollDynamicAnimations correctness ────────────────────────────────────── + +const UNROLL_LOOP_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + const items = ["#a", "#b"]; + for (let i = 0; i < items.length; i++) { + tl.to(items[i], { opacity: 1, duration: 1 }, 0); + } +`; + +const UNROLL_FOREACH_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + ["#a", "#b"].forEach(function(sel) { + tl.to(sel, { opacity: 1, duration: 2 }, 1); + }); +`; + +const UNROLL_ELEMENTS = [ + { + selector: "#hero", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + }, + { + selector: "#sub", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +]; + +function unrollId(script: string): string { + const p = acornId(script); + return p; +} + +describe("unrollDynamicAnimations: acorn output correctness", () => { + it("for-loop: loop replaced with individual tl.to() calls", () => { + const id = unrollId(UNROLL_LOOP_SCRIPT); + const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).not.toBe(UNROLL_LOOP_SCRIPT); + expect(out).toContain('tl.to("#hero"'); + expect(out).toContain('tl.to("#sub"'); + expect(out).not.toContain("for ("); + }); + + it("forEach: loop replaced with individual tl.to() calls", () => { + const id = unrollId(UNROLL_FOREACH_SCRIPT); + const out = unrollAcorn(UNROLL_FOREACH_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).toContain('tl.to("#hero"'); + expect(out).not.toContain("forEach"); + }); + + it("preserves duration and position from original tween", () => { + const id = unrollId(UNROLL_LOOP_SCRIPT); + const out = unrollAcorn(UNROLL_LOOP_SCRIPT, id, UNROLL_ELEMENTS); + expect(out).toContain("duration: 1"); + expect(out).toContain("}, 0)"); + }); + + it("no-op when animationId not found", () => { + expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index 7ec9bd975..e37dc08e9 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -1632,3 +1632,88 @@ export function splitAnimationsInScript( return { script: result, skippedSelectors }; } + +// ── Unroll dynamic animations ──────────────────────────────────────────────── + +function isLoopNode(node: any): boolean { + const t = node?.type; + return ( + t === "ForStatement" || + t === "ForInStatement" || + t === "ForOfStatement" || + t === "WhileStatement" + ); +} + +function isForEachStatement(node: any): boolean { + return ( + node?.type === "ExpressionStatement" && + node.expression?.type === "CallExpression" && + node.expression.callee?.property?.name === "forEach" + ); +} + +function findEnclosingLoop(ancestors: any[]): { start: number; end: number } | null { + for (let i = ancestors.length - 2; i >= 0; i--) { + const node = ancestors[i]; + if (isLoopNode(node) || isForEachStatement(node)) { + return { start: node.start as number, end: node.end as number }; + } + } + return null; +} + +function buildUnrollReplacement( + timelineVar: string, + animation: GsapAnimation, + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>, +): string { + const duration = typeof animation.duration === "number" ? animation.duration : 8; + const ease = typeof animation.ease === "string" ? animation.ease : "none"; + const pos = animation.position ?? 0; + const posCode = typeof pos === "number" ? String(pos) : JSON.stringify(pos); + const calls = elements.map((el) => { + const sorted = [...el.keyframes].sort((a, b) => a.percentage - b.percentage); + const kfCode = buildKeyframeObjectCode(sorted, el.easeEach); + return `${timelineVar}.to(${JSON.stringify(el.selector)}, { keyframes: ${kfCode}, duration: ${duration}, ease: ${JSON.stringify(ease)} }, ${posCode});`; + }); + return calls.join("\n "); +} + +export type UnrollElement = { + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; +}; + +/** + * Replace a dynamic loop that generates multiple tween calls with individual + * static `tl.to()` calls — one per element. Finds the loop containing the + * animation and replaces the entire loop body with unrolled static calls. + */ +export function unrollDynamicAnimations( + script: string, + animationId: string, + elements: UnrollElement[], +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const target = parsed.located.find((l) => l.id === animationId); + if (!target) return script; + + const replacement = buildUnrollReplacement(parsed.timelineVar, target.animation, elements); + const ms = new MagicString(script); + const loop = findEnclosingLoop(target.call.ancestors); + if (loop) { + ms.overwrite(loop.start, loop.end, replacement); + } else { + const stmt = findEnclosingExpressionStatement(target.call.ancestors); + if (!stmt) return script; + ms.overwrite(stmt.start as number, stmt.end as number, replacement); + } + return ms.toString(); +} diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index a520beb23..5968d2810 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -61,6 +61,7 @@ import { setArcPathInScript, updateArcSegmentInScript, removeArcPathFromScript, + unrollDynamicAnimations, } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; @@ -208,6 +209,12 @@ function applyArcPathOp(parsed: ParsedDocument, op: EditOp): MutationResult | un ); case "removeArcPath": return handleArcPathScript(parsed, s, removeArcPathFromScript(s, op.animationId)); + case "unrollDynamicAnimations": + return handleArcPathScript( + parsed, + s, + unrollDynamicAnimations(s, op.animationId, op.elements), + ); default: return undefined; } @@ -1107,6 +1114,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { case "setArcPath": case "updateArcSegment": case "removeArcPath": + case "unrollDynamicAnimations": case "deleteAllForSelector": case "removeLabel": if (getGsapScript(parsed.document) === null) diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 665843ce0..1abc560a3 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -157,7 +157,16 @@ export type EditOp = cp2?: { x: number; y: number }; }; } - | { type: "removeArcPath"; animationId: string }; + | { type: "removeArcPath"; animationId: string } + | { + type: "unrollDynamicAnimations"; + animationId: string; + elements: Array<{ + selector: string; + keyframes: Array<{ percentage: number; properties: Record }>; + easeEach?: string; + }>; + }; export interface ElasticHold { start: number; From 7cad1830a6e764be78fb06ca3167f7e0701d8e87 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 09:56:55 -0700 Subject: [PATCH 2/3] test(core): recast-vs-acorn parity + acorn fixes for arc/unroll/keyframe-add/%-removeKeyframe/add-with-keyframes (WS-3.F gate) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/parsers/gsapWriter.parity.test.ts | 387 +++++++++++++++++- 1 file changed, 386 insertions(+), 1 deletion(-) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index e1b9fa652..d9a4de80a 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -15,9 +15,20 @@ import { materializeKeyframesInScript as materializeRecast, splitIntoPropertyGroups as splitGroupsRecast, splitAnimationsInScript as splitAnimsRecast, + setArcPathInScript as setArcRecast, + updateArcSegmentInScript as updateArcSegmentRecast, + removeArcPathFromScript as removeArcRecast, + unrollDynamicAnimations as unrollRecast, + addKeyframeToScript as addKeyframeRecast, + removeKeyframeFromScript as removeKeyframeRecast, + addAnimationWithKeyframesToScript as addWithKfRecast, type SplitAnimationsOptions, } from "./gsapParser.js"; -import { parseGsapScriptAcornForWrite, type ParsedGsapAcornForWrite } from "./gsapParserAcorn.js"; +import { + parseGsapScriptAcorn, + parseGsapScriptAcornForWrite, + type ParsedGsapAcornForWrite, +} from "./gsapParserAcorn.js"; import { removeAllKeyframesFromScript as removeAllAcorn, convertToKeyframesFromScript as convertAcorn, @@ -28,12 +39,36 @@ import { updateArcSegmentInScript as updateArcSegmentAcorn, removeArcPathFromScript as removeArcAcorn, unrollDynamicAnimations as unrollAcorn, + addKeyframeToScript as addKeyframeAcorn, + removeKeyframeFromScript as removeKeyframeAcorn, + addAnimationWithKeyframesToScript as addWithKfAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; return parsed.located[0]!.id; } +/** + * True recast-vs-acorn differential: parse a written script with the acorn + * parser and strip per-parse metadata, leaving only the AUTHORED animation + * shape. Both writers must produce scripts that reparse to the same model + * (raw text differs — recast pretty-prints, acorn splices in place). + */ +function modelOf(script: string) { + return parseGsapScriptAcorn(script).animations.map((a) => { + // Drop per-parse metadata; compare AUTHORED shape only. + const { + id: _id, + resolvedStart: _resolvedStart, + implicitPosition: _implicitPosition, + propertyGroup: _propertyGroup, + provenance: _provenance, + ...rest + } = a; + return rest; + }); +} + function arcShapeOf(script: string) { const anim = parseGsapScript(script).animations[0]!; return { arcPath: anim.arcPath, properties: anim.properties }; @@ -422,6 +457,21 @@ function arcFixture() { return { id, enabled }; } +// Multi-waypoint fixture: keyframes drive >2 path waypoints and >1 segment, and +// autoRotate is on — exercises the multi-segment branch of buildMotionPathObjectCode. +const ARC_KEYFRAME_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { + keyframes: { "0%": { x: 0, y: 0 }, "50%": { x: 50, y: 100 }, "100%": { x: 200, y: 0 } }, + duration: 2 + }, 0); +`; +const ARC_KEYFRAME_CFG = { + enabled: true as const, + autoRotate: true as const, + segments: [{ curviness: 1.5 }, { curviness: 0.5 }], +}; + describe("setArcPathInScript: acorn output correctness", () => { it("enable: arcPath.enabled=true, segments preserved", () => { const id = acornId(ARC_FLAT_SCRIPT); @@ -529,3 +579,338 @@ describe("unrollDynamicAnimations: acorn output correctness", () => { expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); }); }); + +// ── True recast-vs-acorn differential for the arc trio ────────────────────── +// For each representative input, apply the op via BOTH the recast writer +// (gsapParser.ts) and the acorn writer (gsapWriterAcorn.ts), then assert the +// reparsed authored model is identical. This is the WS-3.F parity safety net: +// acorn cannot drop or mis-serialize a path/segment/restored-xy that recast keeps. +describe("parity: arc path trio (recast vs acorn)", () => { + for (const { name, script, cfg } of [ + { name: "flat x/y — single segment", script: ARC_FLAT_SCRIPT, cfg: ARC_CFG }, + { + name: "keyframes — multi-segment + autoRotate", + script: ARC_KEYFRAME_SCRIPT, + cfg: ARC_KEYFRAME_CFG, + }, + ]) { + describe(name, () => { + it("setArcPath enable: models match", () => { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(setArcAcorn(script, id, cfg))).toEqual( + modelOf(setArcRecast(script, id, cfg)), + ); + }); + + it("updateArcSegment: models match", () => { + const recastEnabled = setArcRecast(script, acornId(script), cfg); + const acornEnabled = setArcAcorn(script, acornId(script), cfg); + const idx = cfg.segments.length - 1; + expect( + modelOf( + updateArcSegmentAcorn(acornEnabled, acornId(acornEnabled), idx, { curviness: 3 }), + ), + ).toEqual( + modelOf( + updateArcSegmentRecast(recastEnabled, acornId(recastEnabled), idx, { curviness: 3 }), + ), + ); + }); + + it("removeArcPath: models match (x/y restored, motionPath gone)", () => { + const recastEnabled = setArcRecast(script, acornId(script), cfg); + const acornEnabled = setArcAcorn(script, acornId(script), cfg); + expect(modelOf(removeArcAcorn(acornEnabled, acornId(acornEnabled)))).toEqual( + modelOf(removeArcRecast(recastEnabled, acornId(recastEnabled))), + ); + }); + }); + } +}); + +// ── forEach with explicit ease + nonzero position — the acorn writer reads +// duration/ease/position from the parsed animation model, recast reads them +// straight from the original tween's AST var/position args. ──────────────────── +const UNROLL_FOREACH_EASE_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + ["#a", "#b"].forEach(function(sel) { + tl.to(sel, { opacity: 1, duration: 2, ease: "power2.out" }, 1); + }); +`; + +// Dynamic tween NOT inside a loop — both writers fall back to replacing the +// enclosing expression statement. String (label) position, no duration/ease, +// so the default duration: 8 / ease: "none" path is exercised on both sides. +const UNROLL_FALLBACK_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#dyn", { opacity: 1 }, "intro"); +`; + +const UNROLL_ELEMENTS_EASE = [ + { + selector: "#hero", + keyframes: [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 100, properties: { opacity: 1 } }, + ], + easeEach: "power1.in", + }, + { + selector: "#sub", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ], + }, +]; + +// True recast-vs-acorn differential: apply unroll via BOTH writers, then assert +// the reparsed (authored-shape) models are identical. Covers a for-loop, a +// forEach with explicit ease + nonzero position, and a non-loop (fallback) +// tween with a label position + defaulted duration/ease. +const UNROLL_PARITY_CASES: Array<{ + name: string; + script: string; + elements: typeof UNROLL_ELEMENTS; +}> = [ + { name: "for-loop", script: UNROLL_LOOP_SCRIPT, elements: UNROLL_ELEMENTS }, + { name: "forEach", script: UNROLL_FOREACH_SCRIPT, elements: UNROLL_ELEMENTS }, + { + name: "forEach with ease + nonzero position", + script: UNROLL_FOREACH_EASE_SCRIPT, + elements: UNROLL_ELEMENTS_EASE, + }, + { + name: "non-loop fallback — label position, defaulted duration/ease", + script: UNROLL_FALLBACK_SCRIPT, + elements: UNROLL_ELEMENTS_EASE, + }, +]; + +describe("parity: unrollDynamicAnimations (recast vs acorn)", () => { + for (const { name, script, elements } of UNROLL_PARITY_CASES) { + it(name, () => { + const id = unrollId(script); + // Sanity: recast and acorn agree on the id for the dynamic tween. + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + + const recastOut = unrollRecast(script, id, elements); + const acornOut = unrollAcorn(script, id, elements); + + // Both writers must actually unroll (not no-op). + expect(recastOut).not.toBe(script); + expect(acornOut).not.toBe(script); + + // Reparsed authored models must be identical. + expect(modelOf(acornOut)).toEqual(modelOf(recastOut)); + }); + } + + it("no-op parity when animationId not found", () => { + expect(unrollAcorn(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + expect(unrollRecast(UNROLL_LOOP_SCRIPT, "nope", UNROLL_ELEMENTS)).toBe(UNROLL_LOOP_SCRIPT); + }); +}); + +// ── addKeyframeToScript parity (recast vs acorn) ──────────────────────────── +// PR #1470 routes Studio's GSAP keyframe-add through the acorn writer. Each +// case applies the op via BOTH writers and asserts the parsed authored models +// are equal — closing the parity gap the keyframe-add fix enforces (the acorn +// whole-value overwrite branch must emit recordToCode, not a stale valueCode). + +// Two distinct percentages so adding/merging exercises the insert + merge paths. +const KF_ADD_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// `_auto`-marked endpoints exercise the adjacent-endpoint sync branch. +const KF_ADD_AUTO_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0, _auto: 1 }, "100%": { opacity: 1, _auto: 1 } }, duration: 0.5 }, 0.2); +`; + +// Flat tween — adding a keyframe runs the convert-to-keyframes path first. +const KF_ADD_FLAT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#hero", { opacity: 1, duration: 0.5, ease: "power3.out" }, 0.2); +`; + +// A percentage entry whose value is NOT an object literal — exercises the +// whole-value overwrite branch (the acorn path here once referenced an +// undefined `valueCode`; recast emits the new value node). +const KF_ADD_NON_OBJECT_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": 0.7, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +describe("parity: addKeyframeToScript (recast vs acorn)", () => { + function expectParity( + script: string, + percentage: number, + properties: Record, + ease?: string, + backfillDefaults?: Record, + ) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + const acorn = addKeyframeAcorn(script, id, percentage, properties, ease, backfillDefaults); + const recast = addKeyframeRecast(script, id, percentage, properties, ease, backfillDefaults); + expect(modelOf(acorn)).toEqual(modelOf(recast)); + } + + it("inserts a new percentage in sorted order", () => { + expectParity(KF_ADD_SCRIPT, 25, { opacity: 0.3 }); + }); + + it("replaces the value when the percentage already exists", () => { + expectParity(KF_ADD_SCRIPT, 100, { opacity: 0.99 }); + }); + + it("merges a new property into an existing keyframe, preserving siblings", () => { + expectParity(KF_ADD_SCRIPT, 100, { x: 100 }); + }); + + it("carries an ease onto the new keyframe", () => { + expectParity(KF_ADD_SCRIPT, 30, { opacity: 0.4 }, "power2.out"); + }); + + it("backfills a new property across sibling keyframes", () => { + expectParity(KF_ADD_SCRIPT, 25, { x: 50 }, undefined, { x: 0 }); + }); + + it("syncs an adjacent _auto 0% endpoint", () => { + expectParity(KF_ADD_AUTO_SCRIPT, 10, { opacity: 0.2 }); + }); + + it("syncs an adjacent _auto 100% endpoint", () => { + expectParity(KF_ADD_AUTO_SCRIPT, 90, { opacity: 0.8 }); + }); + + it("converts a flat tween to keyframes before inserting", () => { + expectParity(KF_ADD_FLAT_SCRIPT, 50, { opacity: 0.5 }); + }); + + it("overwrites a non-object keyframe value with the new properties", () => { + expectParity(KF_ADD_NON_OBJECT_SCRIPT, 50, { opacity: 0.5 }); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(addKeyframeAcorn(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT); + expect(addKeyframeRecast(KF_ADD_SCRIPT, "bad-id", 50, { opacity: 0.5 })).toBe(KF_ADD_SCRIPT); + }); +}); + +// ── removeKeyframeFromScript parity (recast vs acorn) ─────────────────────── +// When removal drops a keyframes block below two stops it must collapse back to +// a flat tween (recast via collapseKeyframesToFlat). The acorn writer must +// mirror this — folding the survivor (incl. `_auto`), dropping per-keyframe +// `ease` and the sibling `easeEach` — or the SDK/server paths diverge. + +// Three plain keyframes — removing the interior one stays a keyframes block. +const RM_PLAIN_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "50%": { opacity: 0.5 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// Two plain keyframes — removing one drops below 2 → collapse to flat tween. +const RM_TWO_KF_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +// Two _auto endpoints — collapse must carry the surviving `_auto` marker. +const RM_TWO_KF_AUTO_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 1, _auto: 1 }, "100%": { opacity: 0, _auto: 1 } }, duration: 0.5 }, 0.2); +`; + +// Survivor carries a per-keyframe `ease`, plus a sibling `easeEach`. Collapse +// must drop both `ease` (per-keyframe) and `easeEach` (keyframes-only). +const RM_TWO_KF_EASE_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": { opacity: 0 }, "100%": { opacity: 1, ease: "power2.in" }, easeEach: "none" }, duration: 0.5 }, 0.2); +`; + +// Survivor is empty — collapse yields a tween with NO authored props. +const RM_TWO_KF_EMPTY_SCRIPT = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#box", { keyframes: { "0%": {}, "100%": { opacity: 1 } }, duration: 0.5 }, 0.2); +`; + +describe("parity: removeKeyframeFromScript (recast vs acorn)", () => { + function expectParity(script: string, percentage: number) { + const id = acornId(script); + expect(parseGsapScript(script).animations[0]!.id).toBe(id); + expect(modelOf(removeKeyframeAcorn(script, id, percentage))).toEqual( + modelOf(removeKeyframeRecast(script, id, percentage)), + ); + } + + it("removes an interior keyframe and stays a keyframes block (3 → 2)", () => { + expectParity(RM_PLAIN_SCRIPT, 50); + }); + + it("targets a near-coincident percentage (51 → the 50% keyframe) at parity", () => { + expectParity(RM_PLAIN_SCRIPT, 51); + }); + + it("collapses to a flat tween when only one keyframe would remain (2 → 1)", () => { + expectParity(RM_TWO_KF_SCRIPT, 0); + }); + + it("collapses an _auto endpoint pair, carrying the surviving _auto marker", () => { + expectParity(RM_TWO_KF_AUTO_SCRIPT, 0); + }); + + it("collapses and drops per-keyframe ease + easeEach", () => { + expectParity(RM_TWO_KF_EASE_SCRIPT, 0); + }); + + it("collapses to a propless flat tween when the surviving keyframe is empty", () => { + expectParity(RM_TWO_KF_EMPTY_SCRIPT, 100); + }); + + it("no-op on unknown id agrees between writers", () => { + expect(removeKeyframeAcorn(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT); + expect(removeKeyframeRecast(RM_TWO_KF_SCRIPT, "bad-id", 0)).toBe(RM_TWO_KF_SCRIPT); + }); +}); + +// ── addAnimationWithKeyframesToScript parity (recast vs acorn) ─────────────── +// WS-3.C add path: both writers insert a new keyframed tl.to() call. The +// inserted statement's authored model (selector, keyframes, duration, ease, +// position) must match — comparing the LAST animation each writer produced. +const ADD_WITH_KF_BASE = ` + const tl = gsap.timeline({ paused: true }); + tl.to("#existing", { opacity: 1, duration: 1 }, 0); +`; + +function lastModelOf(script: string) { + const arr = modelOf(script); + return arr[arr.length - 1]; +} + +describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => { + it("minimal: two-keyframe insert, no ease", () => { + const kfs = [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 200 } }, + ]; + const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script; + const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#hero", 0, 1, kfs).script; + expect(lastModelOf(acorn)).toEqual(lastModelOf(recast)); + }); + + it("moderate: three keyframes, per-keyframe ease, easeEach, nonzero position", () => { + const kfs = [ + { percentage: 0, properties: { x: 0, opacity: 0 } }, + { percentage: 50, properties: { x: 100, opacity: 0.5 }, ease: "power2.out" }, + { percentage: 100, properties: { x: 300, opacity: 1 } }, + ]; + const acorn = addWithKfAcorn(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script; + const recast = addWithKfRecast(ADD_WITH_KF_BASE, "#card", 1.5, 2.25, kfs, "none").script; + expect(lastModelOf(acorn)).toEqual(lastModelOf(recast)); + }); +}); From 8d17b08f8a3b21da91fe5cb8e14bfcb5a6ffb4ba Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 17 Jun 2026 10:45:26 -0700 Subject: [PATCH 3/3] feat(core): port shiftPositions/scalePositions to acorn writer (WS-3.F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shiftPositionsInScript + scalePositionsInScript were recast-only GSAP-script writers reachable from executeGsapMutation (shift-positions/scale-positions), called by Studio timeline clip move/resize — the last write ops blocking recast retirement. Ported to gsapWriterAcorn.ts mirroring recast's arithmetic (shift: max(0,pos+delta); scale: remap pos by duration ratio + scale duration), reusing a shared overwritePosition helper (also adopted by updateAnimationInScript). Adds 10 recast-vs-acorn parity tests. Closes the WS-3.F op-coverage gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/parsers/gsapWriter.parity.test.ts | 75 +++++++++++++++++ packages/core/src/parsers/gsapWriterAcorn.ts | 83 +++++++++++++++++-- 2 files changed, 151 insertions(+), 7 deletions(-) diff --git a/packages/core/src/parsers/gsapWriter.parity.test.ts b/packages/core/src/parsers/gsapWriter.parity.test.ts index d9a4de80a..2a478bb0a 100644 --- a/packages/core/src/parsers/gsapWriter.parity.test.ts +++ b/packages/core/src/parsers/gsapWriter.parity.test.ts @@ -22,6 +22,8 @@ import { addKeyframeToScript as addKeyframeRecast, removeKeyframeFromScript as removeKeyframeRecast, addAnimationWithKeyframesToScript as addWithKfRecast, + shiftPositionsInScript as shiftRecast, + scalePositionsInScript as scaleRecast, type SplitAnimationsOptions, } from "./gsapParser.js"; import { @@ -42,6 +44,8 @@ import { addKeyframeToScript as addKeyframeAcorn, removeKeyframeFromScript as removeKeyframeAcorn, addAnimationWithKeyframesToScript as addWithKfAcorn, + shiftPositionsInScript as shiftAcorn, + scalePositionsInScript as scaleAcorn, } from "./gsapWriterAcorn.js"; function acornId(script: string): string { const parsed = parseGsapScriptAcornForWrite(script) as ParsedGsapAcornForWrite; @@ -914,3 +918,74 @@ describe("parity: addAnimationWithKeyframesToScript (recast vs acorn)", () => { expect(lastModelOf(acorn)).toEqual(lastModelOf(recast)); }); }); + +// ── shiftPositionsInScript / scalePositionsInScript (timeline clip move/resize) ── + +const POSITIONS_MULTI = `const tl = gsap.timeline({ paused: true }); +tl.from("#hero", { opacity: 0, duration: 1 }, 0); +tl.to("#hero", { opacity: 0, duration: 0.5 }, 2.5); +tl.from("#bg", { scale: 0, duration: 1 }, 1);`; + +describe("parity: shiftPositionsInScript (recast vs acorn)", () => { + it("shifts only the target selector's numeric positions", () => { + const a = shiftAcorn(POSITIONS_MULTI, "#hero", 3); + const r = shiftRecast(POSITIONS_MULTI, "#hero", 3); + expect(modelOf(a)).toEqual(modelOf(r)); + }); + + it("clamps negative-going positions to zero", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 0.3); +tl.to("#el", { y: 50, duration: 1 }, 1.5);`; + expect(modelOf(shiftAcorn(s, "#el", -1))).toEqual(modelOf(shiftRecast(s, "#el", -1))); + }); + + it("skips string (relative) positions", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 2); +tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; + expect(modelOf(shiftAcorn(s, "#el", 1))).toEqual(modelOf(shiftRecast(s, "#el", 1))); + }); + + it("adjacent positions do not collide", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#burst", { opacity: 1, duration: 0.5 }, 1.0); +tl.to("#burst", { opacity: 0, duration: 0.5 }, 1.5);`; + expect(modelOf(shiftAcorn(s, "#burst", 0.5))).toEqual(modelOf(shiftRecast(s, "#burst", 0.5))); + }); + + it("implicit-position tween gains an explicit shifted position", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 1, duration: 1 });`; + expect(modelOf(shiftAcorn(s, "#el", 2))).toEqual(modelOf(shiftRecast(s, "#el", 2))); + }); + + it("no matching selector is a no-op", () => { + expect(shiftAcorn(POSITIONS_MULTI, "#nope", 3)).toBe(POSITIONS_MULTI); + }); +}); + +describe("parity: scalePositionsInScript (recast vs acorn)", () => { + it("scales positions and durations proportionally for the target", () => { + const a = scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 2); + const r = scaleRecast(POSITIONS_MULTI, "#hero", 0, 1, 2, 2); + expect(modelOf(a)).toEqual(modelOf(r)); + }); + + it("skips string (relative) positions", () => { + const s = `const tl = gsap.timeline({ paused: true }); +tl.to("#el", { x: 100, duration: 1 }, 2); +tl.to("#el", { y: 50, duration: 1 }, "+=0.5");`; + expect(modelOf(scaleAcorn(s, "#el", 0, 1, 1, 2))).toEqual( + modelOf(scaleRecast(s, "#el", 0, 1, 1, 2)), + ); + }); + + it("no-op when oldDuration <= 0", () => { + expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 0, 2, 2)).toBe(POSITIONS_MULTI); + }); + + it("no-op when newDuration <= 0", () => { + expect(scaleAcorn(POSITIONS_MULTI, "#hero", 0, 1, 2, 0)).toBe(POSITIONS_MULTI); + }); +}); diff --git a/packages/core/src/parsers/gsapWriterAcorn.ts b/packages/core/src/parsers/gsapWriterAcorn.ts index e37dc08e9..0a996b686 100644 --- a/packages/core/src/parsers/gsapWriterAcorn.ts +++ b/packages/core/src/parsers/gsapWriterAcorn.ts @@ -331,18 +331,87 @@ export function updateAnimationInScript( } if (updates.position !== undefined) { - const posIdx = call.method === "fromTo" ? 3 : 2; - const posArgNode = call.node.arguments?.[posIdx]; - if (posArgNode) { - ms.overwrite(posArgNode.start, posArgNode.end, valueToCode(updates.position)); - } else { - ms.appendLeft(call.node.end - 1, `, ${valueToCode(updates.position)}`); - } + overwritePosition(ms, call, updates.position); } return ms.toString(); } +/** + * Overwrite a tween call's numeric position argument (the positionArg the parser + * located: 3rd arg for fromTo, else 2nd), or append one when the call has no + * explicit position. Shared by updateAnimationInScript and the + * shift/scalePositionsInScript timeline ops. + */ +function overwritePosition(ms: MagicString, call: TweenCallInfo, position: number | string): void { + if (call.positionArg) { + ms.overwrite(call.positionArg.start, call.positionArg.end, valueToCode(position)); + } else { + ms.appendLeft(call.node.end - 1, `, ${valueToCode(position)}`); + } +} + +/** + * Shift every tween targeting `targetSelector` by `delta` seconds (clamped ≥0), + * rewriting each call's position argument. Mirrors recast's shiftPositionsInScript + * (used by timeline clip-move to keep GSAP positions in sync with the clip start). + */ +export function shiftPositionsInScript( + script: string, + targetSelector: string, + delta: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max(0, Math.round((entry.animation.position + delta) * 1000) / 1000); + overwritePosition(ms, entry.call, newPos); + changed = true; + } + return changed ? ms.toString() : script; +} + +/** + * Linearly remap every tween targeting `targetSelector` from the old clip + * [oldStart, oldDuration] onto the new [newStart, newDuration] (position and, + * when present, duration scaled by the duration ratio). Mirrors recast's + * scalePositionsInScript (used by timeline clip-resize). + */ +export function scalePositionsInScript( + script: string, + targetSelector: string, + oldStart: number, + oldDuration: number, + newStart: number, + newDuration: number, +): string { + if (oldDuration <= 0 || newDuration <= 0) return script; + const ratio = newDuration / oldDuration; + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + const ms = new MagicString(script); + let changed = false; + for (const entry of parsed.located) { + if (entry.animation.targetSelector !== targetSelector) continue; + if (typeof entry.animation.position !== "number") continue; + const newPos = Math.max( + 0, + Math.round((newStart + (entry.animation.position - oldStart) * ratio) * 1000) / 1000, + ); + overwritePosition(ms, entry.call, newPos); + if (typeof entry.animation.duration === "number" && entry.animation.duration > 0) { + const newDur = Math.max(0.001, Math.round(entry.animation.duration * ratio * 1000) / 1000); + upsertProp(ms, entry.call.varsArg, "duration", newDur); + } + changed = true; + } + return changed ? ms.toString() : script; +} + export function addAnimationToScript( script: string, animation: Omit,