From fb2e21090fc038f6e89394236746fac60c537f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 28 May 2026 19:16:34 -0400 Subject: [PATCH] feat(studio): GSAP tween editing in Design panel (#1102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(studio): GSAP tween editing in Design panel Add a GSAP animation editor to the studio Design panel: select an element, view and edit its tweens (properties, easing, timing), add/delete animations, and drag custom bezier speed curves — all persisted back to the composition HTML. Gated behind VITE_STUDIO_ENABLE_GSAP_PANEL. Parsing of existing GSAP source now uses a recast + Babel AST parser instead of regex, giving scope resolution, stable tween IDs, and round-trip preservation of extras and unresolved raw values. recast compiles to CommonJS that calls require("fs"), which breaks browser and Vite SSR bundles. To contain it, @hyperframes/core is split into an isomorphic layer and a Node-only AST layer: - gsapSerialize.ts holds the recast-free helpers (serialization, keyframe conversion, validation, shared types). htmlParser.ts is now fully isomorphic. - parseGsapScript and the script-mutation helpers live in gsapParser.ts, reachable only via the @hyperframes/core/gsap-parser subpath, loaded server-side by the studio-api mutation routes and the linter via dynamic import (recast stays external under SSR). - The barrel and the gsap-constants subpath are recast-free, so studio browser bundles never trace recast. Adds AST parser unit + stress coverage and e2e helpers for the panel. * fix(lint): await async lintHyperframeHtml in all callers lintHyperframeHtml became async (gsap rules use dynamic import) but lintProject and check-hyperframe-static weren't awaiting it, causing typecheck failures and runtime crashes in CI. Also wire LintRule type in gsap rules to fix fallow unused-type finding, and suppress render.ts exported-for-tests symbols. --- .fallowrc.jsonc | 5 + bun.lock | 28 +- packages/cli/src/commands/lint.ts | 2 +- packages/cli/src/commands/preview.ts | 2 +- packages/cli/src/commands/publish.ts | 2 +- packages/cli/src/commands/render.ts | 2 +- packages/cli/src/server/studioServer.ts | 2 +- packages/cli/src/utils/lintProject.test.ts | 186 ++-- packages/cli/src/utils/lintProject.ts | 6 +- packages/core/package.json | 20 +- .../core/scripts/check-hyperframe-static.ts | 4 +- packages/core/src/compiler/htmlBundler.ts | 2 +- packages/core/src/compiler/staticGuard.ts | 6 +- packages/core/src/index.test.ts | 21 +- packages/core/src/index.ts | 15 +- .../core/src/lint/hyperframeLinter.test.ts | 16 +- packages/core/src/lint/hyperframeLinter.ts | 6 +- packages/core/src/lint/rules/adapters.test.ts | 32 +- packages/core/src/lint/rules/captions.test.ts | 24 +- .../core/src/lint/rules/composition.test.ts | 212 ++-- packages/core/src/lint/rules/core.test.ts | 56 +- packages/core/src/lint/rules/fonts.test.ts | 48 +- packages/core/src/lint/rules/gsap.test.ts | 164 +-- packages/core/src/lint/rules/gsap.ts | 41 +- packages/core/src/lint/rules/media.test.ts | 56 +- packages/core/src/lint/rules/textures.test.ts | 40 +- packages/core/src/lint/types.ts | 8 +- packages/core/src/parsers/gsapConstants.ts | 48 + .../src/parsers/gsapParser.stress.test.ts | 952 ++++++++++++++++++ packages/core/src/parsers/gsapParser.test.ts | 286 +++++- packages/core/src/parsers/gsapParser.ts | 817 +++++++-------- packages/core/src/parsers/gsapSerialize.ts | 287 ++++++ packages/core/src/parsers/htmlParser.ts | 97 +- .../studio-api/helpers/sourceMutation.test.ts | 38 + .../src/studio-api/helpers/sourceMutation.ts | 11 +- packages/core/src/studio-api/routes/files.ts | 183 ++++ packages/producer/src/server.ts | 2 +- .../producer/src/services/hyperframeLint.ts | 4 +- .../src/components/StudioRightPanel.tsx | 18 + .../src/components/editor/AnimationCard.tsx | 325 ++++++ .../components/editor/EaseCurveSection.tsx | 213 ++++ .../editor/GsapAnimationSection.tsx | 112 +++ .../src/components/editor/PropertyPanel.tsx | 66 +- .../src/components/editor/domEditingTypes.ts | 2 + .../editor/gsapAnimationConstants.ts | 130 +++ .../editor/manualEditingAvailability.ts | 6 + .../src/components/editor/manualEdits.test.ts | 101 ++ .../src/components/editor/manualEdits.ts | 31 +- .../src/components/editor/manualEditsDom.ts | 43 +- .../editor/manualOffsetDrag.test.ts | 71 +- .../src/components/editor/manualOffsetDrag.ts | 8 +- .../editor/propertyPanelPrimitives.tsx | 7 +- .../studio/src/contexts/DomEditContext.tsx | 27 + .../studio/src/hooks/useDomEditSession.ts | 100 +- packages/studio/src/hooks/useDomSelection.ts | 8 + .../studio/src/hooks/useGsapScriptCommits.ts | 303 ++++++ .../studio/src/hooks/useGsapTweenCache.ts | 80 ++ .../studio/src/hooks/usePreviewPersistence.ts | 1 + packages/studio/tests/e2e/helpers.sh | 88 ++ packages/studio/vite.adapter.ts | 2 +- packages/studio/vite.config.ts | 9 + 61 files changed, 4354 insertions(+), 1128 deletions(-) create mode 100644 packages/core/src/parsers/gsapConstants.ts create mode 100644 packages/core/src/parsers/gsapParser.stress.test.ts create mode 100644 packages/core/src/parsers/gsapSerialize.ts create mode 100644 packages/studio/src/components/editor/AnimationCard.tsx create mode 100644 packages/studio/src/components/editor/EaseCurveSection.tsx create mode 100644 packages/studio/src/components/editor/GsapAnimationSection.tsx create mode 100644 packages/studio/src/components/editor/gsapAnimationConstants.ts create mode 100644 packages/studio/src/hooks/useGsapScriptCommits.ts create mode 100644 packages/studio/src/hooks/useGsapTweenCache.ts create mode 100644 packages/studio/tests/e2e/helpers.sh diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 55d1da9f2..6d92412e6 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -79,6 +79,11 @@ "refreshDomEditSelection", ], }, + // Exported for render.test.ts (exported-for-tests pattern). + { + "file": "packages/cli/src/commands/render.ts", + "exports": ["resolveBrowserGpuForCli", "renderLocal"], + }, ], "ignoreDependencies": [ // Runtime/dynamic deps not visible to static analysis: tsup `external`, diff --git a/bun.lock b/bun.lock index 2d52b7a44..00c10ccde 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.6.29", + "version": "0.6.51", "bin": { "hyperframes": "./dist/cli.js", }, @@ -99,10 +99,12 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { + "@babel/parser": "^7.27.0", "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", + "recast": "^0.23.11", }, "devDependencies": { "@types/jsdom": "^28.0.0", @@ -126,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -144,7 +146,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.6.29", + "version": "0.6.51", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -156,7 +158,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -196,7 +198,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "html2canvas": "^1.4.1", }, @@ -208,7 +210,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.6.29", + "version": "0.6.51", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -1041,7 +1043,7 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], @@ -1675,6 +1677,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1795,6 +1799,8 @@ "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], @@ -1951,6 +1957,8 @@ "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -1997,6 +2005,8 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "socks-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index ea833113b..73b74555e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -38,7 +38,7 @@ export default defineCommand({ async run({ args }) { try { const project = resolveProject(args.dir); - const lintResult = lintProject(project); + const lintResult = await lintProject(project); if (args.json) { const allFindings = lintResult.results.flatMap((r) => r.result.findings); diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 9ce9afc8b..67b9ce44f 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -129,7 +129,7 @@ export default defineCommand({ const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { const project = { dir, name: projectName, indexPath }; - const lintResult = lintProject(project); + const lintResult = await lintProject(project); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 132cc8edb..182743fcc 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -38,7 +38,7 @@ export default defineCommand({ const indexPath = join(dir, "index.html"); if (existsSync(indexPath)) { - const lintResult = lintProject({ dir, name: projectName, indexPath }); + const lintResult = await lintProject({ dir, name: projectName, indexPath }); if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { console.log(); for (const line of formatLintFindings(lintResult)) console.log(line); diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 86d0a7121..d7de1c6c7 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -479,7 +479,7 @@ export default defineCommand({ // ── Pre-render lint ────────────────────────────────────────────────── { - const lintResult = lintProject(project); + const lintResult = await lintProject(project); if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { console.log(""); for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 8c7ae9b0c..a3eed7efe 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -260,7 +260,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { async lint(html: string, opts?: { filePath?: string }) { const { lintHyperframeHtml } = await import("@hyperframes/core/lint"); - return lintHyperframeHtml(html, opts); + return await lintHyperframeHtml(html, opts); }, runtimeUrl: "/api/runtime.js", diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index 65def2039..157063d95 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -59,9 +59,9 @@ afterEach(() => { }); describe("lintProject", () => { - it("returns zero errors/warnings for a clean project", () => { + it("returns zero errors/warnings for a clean project", async () => { const project = makeProject(validHtml()); - const { totalErrors, totalWarnings, results } = lintProject(project); + const { totalErrors, totalWarnings, results } = await lintProject(project); expect(totalErrors).toBe(0); expect(totalWarnings).toBe(0); @@ -71,9 +71,9 @@ describe("lintProject", () => { expect(first?.file).toBe("index.html"); }); - it("detects errors in index.html", () => { + it("detects errors in index.html", async () => { const project = makeProject(htmlWithMissingMediaId()); - const { totalErrors, results } = lintProject(project); + const { totalErrors, results } = await lintProject(project); expect(totalErrors).toBeGreaterThan(0); const first = results[0]; @@ -82,11 +82,11 @@ describe("lintProject", () => { expect(mediaFinding).toBeDefined(); }); - it("lints sub-compositions in compositions/ directory", () => { + it("lints sub-compositions in compositions/ directory", async () => { const project = makeProject(validHtml(), { "captions.html": htmlWithMissingMediaId(), }); - const { totalErrors, results } = lintProject(project); + const { totalErrors, results } = await lintProject(project); expect(results).toHaveLength(2); const second = results[1]; @@ -97,7 +97,7 @@ describe("lintProject", () => { expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true); }); - it("lints linked CSS next to sub-compositions", () => { + it("lints linked CSS next to sub-compositions", async () => { const project = makeProject(validHtml(), { "scene.html": `
@@ -109,7 +109,7 @@ describe("lintProject", () => { '[data-composition-id="scene"] .title { opacity: 0; }', ); - const { results } = lintProject(project); + const { results } = await lintProject(project); const subResult = results.find((result) => result.file === "compositions/scene.html"); const finding = subResult?.result.findings.find( (item) => item.code === "composition_self_attribute_selector", @@ -119,7 +119,7 @@ describe("lintProject", () => { expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); }); - it("lints percent-encoded linked CSS filenames that exist decoded on disk", () => { + it("lints percent-encoded linked CSS filenames that exist decoded on disk", async () => { const encodedFilename = "%E6%97%A5%E6%9C%AC%E8%AA%9E.css"; const project = makeProject(validHtml(), { "scene.html": ` @@ -132,7 +132,7 @@ describe("lintProject", () => { '[data-composition-id="scene"] .title { opacity: 0; }', ); - const { results } = lintProject(project); + const { results } = await lintProject(project); const subResult = results.find((result) => result.file === "compositions/scene.html"); const finding = subResult?.result.findings.find( (item) => item.code === "composition_self_attribute_selector", @@ -142,11 +142,11 @@ describe("lintProject", () => { expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); }); - it("aggregates errors across index.html and sub-compositions", () => { + it("aggregates errors across index.html and sub-compositions", async () => { const project = makeProject(htmlWithMissingMediaId(), { "overlay.html": htmlWithMissingMediaId(), }); - const { totalErrors, results } = lintProject(project); + const { totalErrors, results } = await lintProject(project); expect(results).toHaveLength(2); const first = results[0]; @@ -159,11 +159,11 @@ describe("lintProject", () => { expect(totalErrors).toBe(rootErrors + subErrors); }); - it("aggregates warnings from sub-compositions", () => { + it("aggregates warnings from sub-compositions", async () => { const project = makeProject(validHtml(), { "captions.html": htmlWithPreloadNone(), }); - const { totalWarnings, results } = lintProject(project); + const { totalWarnings, results } = await lintProject(project); expect(results).toHaveLength(2); expect(totalWarnings).toBeGreaterThan(0); @@ -173,22 +173,22 @@ describe("lintProject", () => { expect(preloadWarning).toBeDefined(); }); - it("handles project with no compositions/ directory", () => { + it("handles project with no compositions/ directory", async () => { const project = makeProject(validHtml()); // No compositions/ dir created - const { results } = lintProject(project); + const { results } = await lintProject(project); expect(results).toHaveLength(1); }); - it("ignores non-HTML files in compositions/", () => { + it("ignores non-HTML files in compositions/", async () => { const project = makeProject(validHtml(), { "captions.html": validHtml("captions"), }); // Add a non-HTML file writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html"); - const { results } = lintProject(project); + const { results } = await lintProject(project); expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt }); @@ -227,11 +227,11 @@ function validHtmlWithMaskImageUrl(url: string): string { } describe("audio_file_without_element", () => { - it("warns when audio file exists but no