From 68e485e0ba40595b56446d32bc55fd91519a9af1 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 13:13:01 -0700 Subject: [PATCH] refactor(studio): gate Acorn writer migration --- packages/parsers/src/gsapParser.test.ts | 1 + packages/parsers/src/gsapParser.ts | 8 +- .../src/gsapWriterAcorn.motionPath.test.ts | 81 +++++++++ packages/parsers/src/gsapWriterAcorn.ts | 162 ++++++++++++++++++ packages/studio-server/package.json | 2 + .../scripts/run-acorn-writer-tests.ts | 19 ++ .../studio-server/src/routes/files.test.ts | 30 ++++ packages/studio-server/src/routes/files.ts | 84 +++++++-- .../routes/gsapMutationCapabilities.report.ts | 3 + .../routes/gsapMutationCapabilities.test.ts | 68 ++++++++ .../src/routes/gsapMutationCapabilities.ts | 109 ++++++++++++ 11 files changed, 547 insertions(+), 20 deletions(-) create mode 100644 packages/parsers/src/gsapWriterAcorn.motionPath.test.ts create mode 100644 packages/studio-server/scripts/run-acorn-writer-tests.ts create mode 100644 packages/studio-server/src/routes/gsapMutationCapabilities.report.ts create mode 100644 packages/studio-server/src/routes/gsapMutationCapabilities.test.ts create mode 100644 packages/studio-server/src/routes/gsapMutationCapabilities.ts diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 11b592f9ae..9bdd1bb1ff 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -2924,6 +2924,7 @@ describe("base gsap.set (off-timeline global hold)", () => { }); expect(script).toContain('gsap.set("#box"'); expect(script).not.toContain('tl.set("#box"'); + expect(script.indexOf('gsap.set("#box"')).toBeLessThan(script.indexOf("gsap.timeline")); }); it("updates a global set in place, keeping it gsap.set", () => { diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 315c238c8f..4e50cdabcf 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -1543,7 +1543,13 @@ export function addAnimationToScript( const id = `anim-${Date.now()}`; const statementCode = buildTweenStatementCode(parsed.timelineVar, animation); const newStatement = parseScript(statementCode).program.body[0]; - insertAfterAnchor(parsed, newStatement); + if (animation.method === "set" && animation.global) { + const timeline = findTimelineDeclarationPath(parsed.ast, parsed.timelineVar); + if (timeline) timeline.insertBefore(newStatement); + else insertAfterAnchor(parsed, newStatement); + } else { + insertAfterAnchor(parsed, newStatement); + } return { script: recast.print(parsed.ast).code, id }; } diff --git a/packages/parsers/src/gsapWriterAcorn.motionPath.test.ts b/packages/parsers/src/gsapWriterAcorn.motionPath.test.ts new file mode 100644 index 0000000000..7bd39b2bb1 --- /dev/null +++ b/packages/parsers/src/gsapWriterAcorn.motionPath.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { + addMotionPathPointInScript as addAcorn, + addMotionPathToScript as addPathAcorn, + removeMotionPathPointInScript as removeAcorn, + syncPositionHoldsBeforeKeyframes as syncAcorn, + updateMotionPathPointInScript as updateAcorn, +} from "./gsapWriterAcorn.js"; +import { + addMotionPathPointInScript as addRecast, + addMotionPathToScript as addPathRecast, + parseGsapScript, + removeMotionPathPointInScript as removeRecast, + syncPositionHoldsBeforeKeyframes as syncRecast, + updateMotionPathPointInScript as updateRecast, +} from "./gsapParser.js"; +import { parseGsapScriptAcorn } from "./gsapParserAcorn.js"; + +const SCRIPT = `// keep-this-comment +const untouched = { spacing: "verbatim" }; +const tl = gsap.timeline({ paused: true }); +tl.to("#box", { motionPath: { path: [{ x: 0, y: 0 }, { x: 100, y: -50 }, { x: 200, y: 0 }], curviness: 1.2 }, duration: 1 }, 2); +window.__timelines = { main: tl };`; + +function idOf(script: string): string { + const id = parseGsapScript(script).animations.find((animation) => animation.arcPath)?.id; + if (!id) throw new Error("motion path fixture did not parse"); + return id; +} + +function motionModel(script: string) { + return parseGsapScriptAcorn(script).animations.map((animation) => ({ + targetSelector: animation.targetSelector, + method: animation.method, + position: animation.position, + duration: animation.duration, + ease: animation.ease, + keyframes: animation.keyframes, + arcPath: animation.arcPath, + })); +} + +function expectParity(acorn: string, recast: string): void { + expect(motionModel(acorn)).toEqual(motionModel(recast)); + expect(acorn).toContain("// keep-this-comment"); + expect(acorn).toContain('const untouched = { spacing: "verbatim" };'); +} + +describe("Acorn motion-path writer parity", () => { + it("moves an anchor while preserving path semantics and untouched source", () => { + const id = idOf(SCRIPT); + expectParity( + updateAcorn(SCRIPT, id, 1, { x: 120, y: -80 }), + updateRecast(SCRIPT, id, 1, { x: 120, y: -80 }), + ); + }); + + it("adds and removes anchors with the same segment behavior as Recast", () => { + const id = idOf(SCRIPT); + expectParity( + addAcorn(SCRIPT, id, 1, { x: 50, y: -20 }), + addRecast(SCRIPT, id, 1, { x: 50, y: -20 }), + ); + expectParity(removeAcorn(SCRIPT, id, 1), removeRecast(SCRIPT, id, 1)); + }); + + it("authors a new path with the same parsed model", () => { + const base = "const tl = gsap.timeline({ paused: true });\nwindow.__timelines = { main: tl };"; + const acorn = addPathAcorn(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script; + const recast = addPathRecast(base, "#hero", 1.5, 2, { x: 300, y: -100 }).script; + expect(motionModel(acorn)).toEqual(motionModel(recast)); + }); + + it("synchronizes delayed position holds without Recast and remains idempotent", () => { + const acorn = syncAcorn(SCRIPT); + const recast = syncRecast(SCRIPT); + expect(motionModel(acorn)).toEqual(motionModel(recast)); + expect(acorn).toContain('data: "hf-hold"'); + expect(syncAcorn(acorn)).toBe(acorn); + }); +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 556eecd093..f1b481bee7 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -2074,6 +2074,105 @@ export function updateArcSegmentInScript( return ms.toString(); } +function hasCubicSegments(segments: ArcPathSegment[]): boolean { + return segments.some((segment) => segment.cp1 != null || segment.cp2 != null); +} + +function writeMotionPathValue( + script: string, + target: ParsedGsapAcornForWrite["located"][number], + waypoints: Array<{ x: number; y: number }>, + segments: ArcPathSegment[], + autoRotate: boolean | number, +): string { + const motionPath = findPropertyNode(target.call.varsArg, "motionPath"); + if (!motionPath) return script; + const code = buildMotionPathObjectCode({ waypoints, segments, autoRotate }); + const ms = new MagicString(script); + ms.overwrite(motionPath.value.start, motionPath.value.end, code); + return ms.toString(); +} + +export function updateMotionPathPointInScript( + script: string, + animationId: string, + pointIndex: number, + point: { x: number; y: number }, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + const target = parsed?.located.find((entry) => entry.id === animationId); + if (!target?.animation.arcPath?.enabled) return script; + const waypoints = extractArcWaypoints(target.animation); + if (waypoints.length < 2 || pointIndex < 0 || pointIndex >= waypoints.length) return script; + const next = waypoints.map((waypoint, index) => (index === pointIndex ? { ...point } : waypoint)); + return writeMotionPathValue( + script, + target, + next, + target.animation.arcPath.segments, + target.animation.arcPath.autoRotate, + ); +} + +export function addMotionPathPointInScript( + script: string, + animationId: string, + index: number, + point: { x: number; y: number }, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + const target = parsed?.located.find((entry) => entry.id === animationId); + const arc = target?.animation.arcPath; + if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script; + const waypoints = extractArcWaypoints(target.animation); + if (index < 1 || index > waypoints.length - 1) return script; + const segments = [...arc.segments]; + waypoints.splice(index, 0, { ...point }); + segments.splice(index - 1, 0, { curviness: segments[index - 1]?.curviness ?? 1 }); + return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate); +} + +export function removeMotionPathPointInScript( + script: string, + animationId: string, + index: number, +): string { + const parsed = parseGsapScriptAcornForWrite(script); + const target = parsed?.located.find((entry) => entry.id === animationId); + const arc = target?.animation.arcPath; + if (!target || !arc?.enabled || hasCubicSegments(arc.segments)) return script; + const waypoints = extractArcWaypoints(target.animation); + if (waypoints.length <= 2 || index < 0 || index >= waypoints.length) return script; + const segments = [...arc.segments]; + waypoints.splice(index, 1); + segments.splice(Math.min(index, segments.length - 1), 1); + return writeMotionPathValue(script, target, waypoints, segments, arc.autoRotate); +} + +export function addMotionPathToScript( + script: string, + targetSelector: string, + position: number, + duration: number, + point: { x: number; y: number }, + ease = "power1.inOut", +): { script: string; id: string | null } { + const motionPath = buildMotionPathObjectCode({ + waypoints: [{ x: 0, y: 0 }, { ...point }], + segments: [{ curviness: 1 }], + autoRotate: false, + }); + const result = addAnimationToScript(script, { + targetSelector, + method: "to", + position, + duration, + ease, + properties: { motionPath: `__raw:${motionPath}` }, + }); + return { script: result.script, id: result.id || null }; +} + export function removeArcPathFromScript(script: string, animationId: string): string { return setArcPathInScript(script, animationId, { enabled: false, @@ -2133,6 +2232,69 @@ function insertInheritedStateSetInScript( return ms.toString(); } +const STUDIO_HOLD_MARKER = "hf-hold"; + +function isStudioHoldSet(animation: GsapAnimation): boolean { + return animation.method === "set" && animation.properties.data === STUDIO_HOLD_MARKER; +} + +function removeStudioHoldSets(script: string, parsed: ParsedGsapAcornForWrite): string { + const staleHolds = parsed.located.filter((entry) => isStudioHoldSet(entry.animation)); + if (staleHolds.length === 0) return script; + const ms = new MagicString(script); + for (const hold of staleHolds) removeCallFromMagicString(ms, hold.call, script); + return ms.toString(); +} + +function animationStart(animation: GsapAnimation): number { + if (animation.resolvedStart !== undefined) return animation.resolvedStart; + return typeof animation.position === "number" ? animation.position : 0; +} + +function positionProperties( + properties: Record, +): Record { + const position: Record = {}; + for (const [property, value] of Object.entries(properties)) { + if (classifyPropertyGroup(property) === "position" && typeof value === "number") { + position[property] = value; + } + } + return position; +} + +function positionHoldForAnimation( + animation: GsapAnimation, +): Record | null { + if (!animation.keyframes) return null; + if (!(animationStart(animation) > 0.001)) return null; + const first = [...animation.keyframes.keyframes].sort( + (left, right) => left.percentage - right.percentage, + )[0]; + if (!first) return null; + const position = positionProperties(first.properties); + return Object.keys(position).length > 0 ? position : null; +} + +/** Acorn-native, byte-preserving hold synchronization used after mutations. */ +export function syncPositionHoldsBeforeKeyframes(script: string): string { + const parsed = parseGsapScriptAcornForWrite(script); + if (!parsed) return script; + let result = removeStudioHoldSets(script, parsed); + const current = parseGsapScriptAcornForWrite(result); + if (!current) return result; + for (const entry of current.located) { + const animation = entry.animation; + const position = positionHoldForAnimation(animation); + if (!position) continue; + result = insertInheritedStateSetInScript(result, animation.targetSelector, 0, { + ...position, + data: STUDIO_HOLD_MARKER, + }); + } + return result; +} + /** * Compute, in forward (timeline) order, the inherited-props baseline available * BEFORE each matching tween, plus the final cumulative state at the split point. diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index fcc5e0b0f6..ec6119b324 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -97,8 +97,10 @@ "scripts": { "build": "tsup", "test": "vitest run", + "test:acorn-writer": "bun scripts/run-acorn-writer-tests.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", + "report:gsap-writers": "bun src/routes/gsapMutationCapabilities.report.ts", "prepublishOnly": "echo skip" }, "dependencies": { diff --git a/packages/studio-server/scripts/run-acorn-writer-tests.ts b/packages/studio-server/scripts/run-acorn-writer-tests.ts new file mode 100644 index 0000000000..d9e5a33c36 --- /dev/null +++ b/packages/studio-server/scripts/run-acorn-writer-tests.ts @@ -0,0 +1,19 @@ +import { fileURLToPath } from "node:url"; + +const child = Bun.spawn( + [ + "bunx", + "vitest", + "run", + "src/routes/files.test.ts", + "src/routes/gsapMutationCapabilities.test.ts", + ], + { + cwd: fileURLToPath(new URL("..", import.meta.url)), + env: { ...process.env, HYPERFRAMES_GSAP_WRITER: "acorn" }, + stdout: "inherit", + stderr: "inherit", + }, +); + +process.exitCode = await child.exited; diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index dd937e769c..78a6088cd2 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -61,6 +61,36 @@ function postElementPatchBatch(app: Hono, file: string, patches: unknown[]): Pro } describe("registerFileRoutes", () => { + it("returns a clean 400 for an invalid GSAP writer flag", async () => { + const previous = process.env.HYPERFRAMES_GSAP_WRITER; + process.env.HYPERFRAMES_GSAP_WRITER = "true"; + try { + const projectDir = createProjectDir(); + writeFileSync( + join(projectDir, "index.html"), + '
', + ); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/gsap-mutations/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "shift-positions", targetSelector: "#box", delta: 1 }), + }, + ); + const payload = (await response.json()) as { error?: string }; + + expect(response.status).toBe(400); + expect(payload.error).toContain("expected recast or acorn"); + } finally { + if (previous === undefined) delete process.env.HYPERFRAMES_GSAP_WRITER; + else process.env.HYPERFRAMES_GSAP_WRITER = previous; + } + }); + it("returns empty content for missing files when caller marks the read optional", async () => { const projectDir = createProjectDir(); const app = new Hono(); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 34c7dfc3c5..ea92d64990 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -51,6 +51,10 @@ import { unrollDynamicAnimations, setArcPathInScript, updateArcSegmentInScript, + updateMotionPathPointInScript, + addMotionPathPointInScript, + removeMotionPathPointInScript, + addMotionPathToScript, removeArcPathFromScript, addAnimationWithKeyframesToScript, splitAnimationsInScript, @@ -58,6 +62,7 @@ import { shiftPositionsInScript, scalePositionsInScript, dedupePositionWritesInScript, + syncPositionHoldsBeforeKeyframes, } from "@hyperframes/parsers/gsap-writer-acorn"; import { removeElementFromHtml, @@ -71,26 +76,18 @@ import { type ElementRebase, } from "../helpers/sourceMutation.js"; import { parseHTML } from "linkedom"; +import { resolveGsapWriter } from "./gsapMutationCapabilities.js"; // ── Server cutover flag ───────────────────────────────────────────────────── /** - * Mirror of the client STUDIO_SDK_CUTOVER_ENABLED flag for server-side writer - * selection. When true, the acorn writer handles GSAP mutations; otherwise the - * recast writer (gsapParser.ts) is used. Default false → recast. - * - * Enable with: STUDIO_SDK_CUTOVER_ENABLED=true (or =1) - * Mirrors the client Vite env var name so one env switch flips both sides. + * Writer selection is deliberately independent from the Studio SDK cutover. + * Recast remains the default until the capability report has no parity blockers. */ -function isAcornGsapWriterEnabled(): boolean { - const val = process.env["STUDIO_SDK_CUTOVER_ENABLED"]; - return val === "true" || val === "1"; -} - /** * Lazy-load gsapParser for write ops (recast-backed) — the default server writer. * The read path uses the browser-safe acorn parser; this loader is only needed - * for the recast write path (the default when STUDIO_SDK_CUTOVER_ENABLED is off). + * for the recast write path (the default until the migration gate graduates). */ async function loadGsapParser() { return import("@hyperframes/parsers/gsap-parser-recast"); @@ -589,7 +586,7 @@ function bakeVisibilityOnDelete(document: Document, anim: GsapAnimation): void { // ── GSAP mutation types ───────────────────────────────────────────────────── -type GsapMutationRequest = +export type GsapMutationRequest = | { type: "update-property"; animationId: string; @@ -866,10 +863,11 @@ async function executeGsapMutation( body: GsapMutationRequest, block: NonNullable>, respond: (data: unknown, status?: number) => Response, + writer: "recast" | "acorn", ): Promise { - // When the server cutover flag is enabled, delegate to the acorn writer; - // otherwise use the recast writer (gsapParser.ts) as the default. - if (!isAcornGsapWriterEnabled()) { + // Keep writer selection explicit at the route boundary so a batch cannot + // switch implementations between operations. + if (writer === "recast") { return executeGsapMutationRecast(body, block, respond); } return executeGsapMutationAcorn(body, block, respond); @@ -959,17 +957,25 @@ async function applyGsapMutations( const skippedSelectors = new Set(); const respond = (data: unknown, status?: number) => status ? c.json(data, status) : c.json(data); + let writer: "recast" | "acorn"; + try { + writer = resolveGsapWriter(process.env); + } catch (error) { + return c.json({ error: error instanceof Error ? error.message : String(error) }, 400); + } for (const mutation of mutations) { - const result = await executeGsapMutation(mutation, block, respond); + const result = await executeGsapMutation(mutation, block, respond, writer); if (result instanceof Response) return result; let newScript = typeof result === "string" ? result : result.script; if (typeof result !== "string") { for (const selector of result.skippedSelectors) skippedSelectors.add(selector); } if (HOLD_SYNC_MUTATION_TYPES.has(mutation.type)) { - const parser = await loadGsapParser(); - newScript = parser.syncPositionHoldsBeforeKeyframes(newScript); + newScript = + writer === "acorn" + ? syncPositionHoldsBeforeKeyframes(newScript) + : (await loadGsapParser()).syncPositionHoldsBeforeKeyframes(newScript); } block.scriptText = newScript; } @@ -1060,6 +1066,14 @@ function executeGsapMutationAcorn( if (body.fromProperties && body.method !== "fromTo") { return respond({ error: "fromProperties is only valid for method=fromTo" }, 400); } + if ( + Object.keys(body.properties).some((key) => { + const group = classifyPropertyGroup(key); + return group === "position" || group === "rotation"; + }) + ) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationToScript(block.scriptText, { targetSelector: body.targetSelector, method: body.method, @@ -1197,10 +1211,38 @@ function executeGsapMutationAcorn( ...(body.cp2 ? { cp2: body.cp2 } : {}), }); } + case "update-motion-path-point": { + return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, { + x: body.x, + y: body.y, + }); + } + case "add-motion-path-point": { + return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, { + x: body.x, + y: body.y, + }); + } + case "remove-motion-path-point": { + return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index); + } + case "add-motion-path": { + return addMotionPathToScript( + block.scriptText, + body.targetSelector, + body.position, + body.duration, + { x: body.x, y: body.y }, + body.ease, + ).script; + } case "remove-arc-path": { return removeArcPathFromScript(block.scriptText, body.animationId); } case "add-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const result = addAnimationWithKeyframesToScript( block.scriptText, body.targetSelector, @@ -1213,6 +1255,9 @@ function executeGsapMutationAcorn( return result.script; } case "replace-with-keyframes": { + if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) { + stripStudioEditsFromTarget(block.document, body.targetSelector); + } const script = removeAnimationFromScript(block.scriptText, body.animationId); const added = addAnimationWithKeyframesToScript( script, @@ -1575,6 +1620,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + body.easeEach, ); return result.script; } diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.report.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.report.ts new file mode 100644 index 0000000000..5a58de6885 --- /dev/null +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.report.ts @@ -0,0 +1,3 @@ +import { renderGsapMutationCapabilityReport } from "./gsapMutationCapabilities.js"; + +process.stdout.write(renderGsapMutationCapabilityReport()); diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts new file mode 100644 index 0000000000..a7e0fe1f04 --- /dev/null +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + GSAP_MUTATION_CAPABILITIES, + acornDefaultBlockers, + renderGsapMutationCapabilityReport, + resolveGsapWriter, +} from "./gsapMutationCapabilities.js"; + +const source = readFileSync(resolve(process.cwd(), "src/routes/files.ts"), "utf8"); + +function dispatcherCases(startMarker: string, endMarker: string): string[] { + const start = source.indexOf(startMarker); + const end = source.indexOf(endMarker, start + startMarker.length); + if (start < 0 || end < 0) throw new Error(`Could not locate dispatcher ${startMarker}`); + return [...source.slice(start, end).matchAll(/case\s+"([^"]+)"/g)].map((match) => match[1]!); +} + +describe("GSAP writer capability matrix", () => { + const operations = Object.keys(GSAP_MUTATION_CAPABILITIES).sort(); + + it("matches every Acorn and Recast dispatcher case", () => { + expect( + dispatcherCases( + "function executeGsapMutationAcorn", + "async function executeGsapMutationRecast", + ).sort(), + ).toEqual(operations); + expect( + dispatcherCases( + "async function executeGsapMutationRecast", + "function registerFileRoutes", + ).sort(), + ).toEqual(operations); + }); + + it("keeps the Acorn dispatcher free of Recast and gates hold sync with the selected writer", () => { + const acornBody = source.slice( + source.indexOf("function executeGsapMutationAcorn"), + source.indexOf("async function executeGsapMutationRecast"), + ); + const mutationHelperBody = source.slice( + source.indexOf("async function applyGsapMutations"), + source.indexOf("function executeGsapMutationAcorn"), + ); + expect(acornBody).not.toContain("loadGsapParser"); + expect(mutationHelperBody).toContain('writer === "acorn"'); + expect(mutationHelperBody).toContain("? syncPositionHoldsBeforeKeyframes(newScript)"); + expect(mutationHelperBody).toContain( + "(await loadGsapParser()).syncPositionHoldsBeforeKeyframes", + ); + }); + + it("renders every classified operation and keeps default blocked until parity is differential", () => { + const report = renderGsapMutationCapabilityReport(); + for (const operation of operations) expect(report).toContain(`| ${operation} |`); + expect(acornDefaultBlockers().length).toBeGreaterThan(0); + }); + + it("defaults to Recast and requires an explicit Acorn canary selection", () => { + expect(resolveGsapWriter({})).toBe("recast"); + expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "acorn" })).toBe("acorn"); + expect(() => resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "unknown" })).toThrow( + "expected recast or acorn", + ); + }); +}); diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.ts new file mode 100644 index 0000000000..b59f2066ab --- /dev/null +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.ts @@ -0,0 +1,109 @@ +import type { GsapMutationRequest } from "./files.js"; + +export type GsapMutationType = GsapMutationRequest["type"]; +type GsapMutationFamily = "animation" | "keyframe" | "motion_path" | "structure" | "timing"; +type GsapParityEvidence = "differential" | "behavioral"; + +interface GsapMutationCapability { + family: GsapMutationFamily; + acorn: "supported"; + recast: "supported"; + parity: GsapParityEvidence; +} + +const differential = (family: GsapMutationFamily): GsapMutationCapability => ({ + family, + acorn: "supported", + recast: "supported", + parity: "differential", +}); +const behavioral = (family: GsapMutationFamily): GsapMutationCapability => ({ + family, + acorn: "supported", + recast: "supported", + parity: "behavioral", +}); + +/** Compile-time exhaustive over the request union; runtime tests match both dispatchers. */ +export const GSAP_MUTATION_CAPABILITIES = { + "update-property": differential("animation"), + "update-properties": differential("animation"), + "update-from-property": differential("animation"), + "update-meta": differential("animation"), + add: differential("animation"), + delete: differential("animation"), + "add-property": differential("animation"), + "add-from-property": differential("animation"), + "remove-property": differential("animation"), + "remove-from-property": differential("animation"), + "add-keyframe": differential("keyframe"), + "remove-keyframe": differential("keyframe"), + "move-keyframe": behavioral("keyframe"), + "resize-keyframed-tween": behavioral("keyframe"), + "update-keyframe": differential("keyframe"), + "convert-to-keyframes": behavioral("keyframe"), + "remove-all-keyframes": behavioral("keyframe"), + "materialize-keyframes": behavioral("keyframe"), + "set-arc-path": behavioral("motion_path"), + "update-arc-segment": behavioral("motion_path"), + "update-motion-path-point": differential("motion_path"), + "add-motion-path-point": differential("motion_path"), + "remove-motion-path-point": differential("motion_path"), + "add-motion-path": differential("motion_path"), + "remove-arc-path": behavioral("motion_path"), + "add-with-keyframes": behavioral("keyframe"), + "replace-with-keyframes": behavioral("keyframe"), + "split-animations": behavioral("structure"), + "split-into-property-groups": behavioral("structure"), + "delete-all-for-selector": behavioral("structure"), + "consolidate-position-writes": behavioral("structure"), + "unroll-timeline": behavioral("structure"), + "shift-positions": behavioral("timing"), + "shift-positions-batch": behavioral("timing"), + "scale-positions": behavioral("timing"), +} as const satisfies Record; + +const GSAP_WRITER_MIGRATION = Object.freeze({ + flag: "HYPERFRAMES_GSAP_WRITER", + owner: "studio-foundations", + deadline: "2026-09-30", + graduationCriteria: + "Every operation has differential parity, the Acorn path imports no Recast runtime, and canary divergence is zero for the agreed soak window.", +}); + +export function resolveGsapWriter(env: { HYPERFRAMES_GSAP_WRITER?: string }): "recast" | "acorn" { + const configured = env.HYPERFRAMES_GSAP_WRITER ?? "recast"; + if (configured === "recast" || configured === "acorn") return configured; + throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`); +} + +export function acornDefaultBlockers(): GsapMutationType[] { + return ( + Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]> + ) + .filter(([, capability]) => capability.parity !== "differential") + .map(([type]) => type); +} + +export function renderGsapMutationCapabilityReport(): string { + const rows = ( + Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]> + ).map( + ([type, capability]) => + `| ${type} | ${capability.family} | ${capability.acorn} | ${capability.recast} | ${capability.parity} |`, + ); + return [ + "# GSAP writer capability report", + "", + `Owner: ${GSAP_WRITER_MIGRATION.owner}`, + `Deadline: ${GSAP_WRITER_MIGRATION.deadline}`, + `Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\``, + "", + "| Operation | Family | Acorn | Recast | Parity evidence |", + "| --- | --- | --- | --- | --- |", + ...rows, + "", + `Default blockers: ${acornDefaultBlockers().join(", ") || "none"}`, + "", + ].join("\n"); +}