Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,3 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"timeout": 180,
"statusMessage": "Running build + lint + typecheck before commit…",
"command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = execSync('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();\n const steps = [\n ['bun run build', 'Build'],\n ['bun run lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\""
}
]
}
]
}
"hooks": {}
}
20 changes: 20 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@
// Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel.
"packages/studio/src/components/editor/KeyframeDiamond.tsx",
"packages/studio/src/components/editor/SpringEaseEditor.tsx",
// MotionPathOverlay — rendered conditionally via DomEditOverlay.
"packages/studio/src/components/editor/MotionPathOverlay.tsx",
// AE-level keyframe Phase 2/3 components — scaffolded, wired in follow-up PRs.
"packages/studio/src/components/editor/DopesheetStrip.tsx",
"packages/studio/src/components/editor/StaggerControls.tsx",
"packages/studio/src/hooks/gsapRuntimePreview.ts",
"packages/studio/src/hooks/useKeyframeKeyboard.ts",
"packages/studio/src/player/components/TimelinePropertyRows.tsx",
"packages/studio/src/utils/audioBeatDetection.ts",
],
"ignorePatterns": [
"docs/**",
Expand Down Expand Up @@ -111,6 +120,17 @@
"createFailedCaptureCalibrationEstimate",
],
},
// propertyPanelHelpers: exported utility functions used conditionally by
// PropertyPanel features behind feature flags or consumed by external tools.
{
"file": "packages/studio/src/components/editor/propertyPanelHelpers.ts",
"exports": [
"EMPTY_FILTER_VALUE",
"BOX_SHADOW_PRESETS",
"clampPanelNumber",
"computeFitToChildrenSize",
],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/generators/hyperframes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import {
} from "../core.types";
import type { GsapAnimation } from "../parsers/gsapParser";
import { serializeGsapAnimations, keyframesToGsapAnimations } from "../parsers/gsapParser";
import { GSAP_CDN, BASE_STYLES, ZOOM_CONTAINER_STYLES } from "../templates/constants";
import {
GSAP_CDN,
MOTIONPATH_CDN,
BASE_STYLES,
ZOOM_CONTAINER_STYLES,
} from "../templates/constants";

const GOOGLE_FONTS_BASE = "https://fonts.googleapis.com/css2";
const FONT_WEIGHTS: Record<string, string> = {
Expand Down Expand Up @@ -337,6 +342,10 @@ export function generateHyperframesHtml(
: "";

const gsapCdnTag = includeScripts ? ` <script src="${GSAP_CDN}"></script>` : "";
const motionPathCdnTag =
includeScripts && gsapScript && /motionPath\s*[:{]/.test(gsapScript)
? `\n <script src="${MOTIONPATH_CDN}"></script>`
: "";

const gsapScriptTag = includeScripts
? ` <script>
Expand Down Expand Up @@ -373,7 +382,7 @@ ${gsapScript}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
${includeStyles ? googleFontsLink : ""}
${gsapCdnTag}
${gsapCdnTag}${motionPathCdnTag}
${styleTags ? ` ${styleTags}` : ""}
</head>
<body>
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/parsers/gsapConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,5 @@ export const SUPPORTED_EASES = [
"spring-stiff",
"spring-wobbly",
"spring-heavy",
"steps(1)",
];
200 changes: 200 additions & 0 deletions packages/core/src/parsers/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
updateKeyframeInScript,
convertToKeyframesInScript,
removeAllKeyframesFromScript,
addAnimationWithKeyframesToScript,
} from "./gsapParser.js";
import type { GsapAnimation } from "./gsapParser.js";
import type { Keyframe } from "../core.types";
Expand Down Expand Up @@ -1504,3 +1505,202 @@ describe("keyframe mutations", () => {
expect(anim.properties.opacity).toBe(1);
});
});

describe("motionPath parsing", () => {
it("parses motionPath with waypoint array and curviness", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: -100}, {x: 400, y: 50}],
curviness: 1.5
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations).toHaveLength(1);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.enabled).toBe(true);
expect(anim.arcPath!.segments).toHaveLength(2);
expect(anim.arcPath!.segments[0].curviness).toBe(1.5);
expect(anim.arcPath!.segments[1].curviness).toBe(1.5);

expect(anim.keyframes).toBeDefined();
expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties.x).toBe(0);
expect(anim.keyframes!.keyframes[0].properties.y).toBe(0);
expect(anim.keyframes!.keyframes[1].properties.x).toBe(200);
expect(anim.keyframes!.keyframes[1].properties.y).toBe(-100);
expect(anim.keyframes!.keyframes[2].properties.x).toBe(400);
expect(anim.keyframes!.keyframes[2].properties.y).toBe(50);
});

it("parses motionPath with type cubic and explicit control points", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [
{x: 0, y: 0},
{x: 50, y: -80}, {x: 150, y: -120},
{x: 200, y: -100},
{x: 250, y: -80}, {x: 350, y: 30},
{x: 400, y: 50}
],
type: "cubic"
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(2);

expect(anim.arcPath!.segments[0].cp1).toEqual({ x: 50, y: -80 });
expect(anim.arcPath!.segments[0].cp2).toEqual({ x: 150, y: -120 });

expect(anim.arcPath!.segments[1].cp1).toEqual({ x: 250, y: -80 });
expect(anim.arcPath!.segments[1].cp2).toEqual({ x: 350, y: 30 });

expect(anim.keyframes!.keyframes).toHaveLength(3);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ x: 200, y: -100 });
expect(anim.keyframes!.keyframes[2].properties).toEqual({ x: 400, y: 50 });
});

it("parses motionPath with autoRotate", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
autoRotate: true
},
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath!.autoRotate).toBe(true);
});

it("merges motionPath waypoints into existing keyframes", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: {
path: [{x: 0, y: 0}, {x: 200, y: 100}],
curviness: 2
},
keyframes: {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
},
duration: 2
}, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];

expect(anim.arcPath).toBeDefined();
expect(anim.arcPath!.segments).toHaveLength(1);
expect(anim.arcPath!.segments[0].curviness).toBe(2);

expect(anim.keyframes!.keyframes).toHaveLength(2);
expect(anim.keyframes!.keyframes[0].properties).toEqual({ opacity: 1, x: 0, y: 0 });
expect(anim.keyframes!.keyframes[1].properties).toEqual({ opacity: 0, x: 200, y: 100 });
});

it("skips motionPath with fewer than 2 waypoints", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", {
motionPath: { path: [{x: 0, y: 0}] },
duration: 1
}, 0);
`;
const result = parseGsapScript(script);
expect(result.animations[0].arcPath).toBeUndefined();
});

it("tween without motionPath parses identically to before", () => {
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#el", { x: 100, y: 200, duration: 1 }, 0);
`;
const result = parseGsapScript(script);
const anim = result.animations[0];
expect(anim.arcPath).toBeUndefined();
expect(anim.properties.x).toBe(100);
expect(anim.properties.y).toBe(200);
});
});

// ── addAnimationWithKeyframesToScript ──────────────────────────────────────

describe("addAnimationWithKeyframesToScript", () => {
const BASE = `
const tl = gsap.timeline({ paused: true });
tl.to("#title", { x: 100, duration: 0.5 }, 0);
`.trim();

it("adds a new tween with keyframes after existing tweens", () => {
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#box", 3, 0.5, [
{ percentage: 0, properties: { x: 0 } },
{ percentage: 100, properties: { x: 200 } },
]);
expect(script).toContain("#box");
expect(script).toContain("keyframes");
expect(script).toContain('"0%"');
expect(script).toContain('"100%"');
expect(id).toBeTruthy();

const parsed = parseGsapScript(script);
expect(parsed.animations.length).toBe(2);
const newAnim = parsed.animations[1];
expect(newAnim.targetSelector).toBe("#box");
expect(newAnim.keyframes).toBeDefined();
expect(newAnim.keyframes!.keyframes.length).toBe(2);
});

it("preserves existing tween code", () => {
const { script } = addAnimationWithKeyframesToScript(BASE, "#new", 2, 1, [
{ percentage: 0, properties: { opacity: 0 } },
{ percentage: 100, properties: { opacity: 1 } },
]);
expect(script).toContain("#title");
expect(script).toContain("x: 100");
});

it("produces a stable ID for the new animation", () => {
const { script, id } = addAnimationWithKeyframesToScript(BASE, "#el", 1, 1, [
{ percentage: 0, properties: { y: 0 } },
{ percentage: 100, properties: { y: 100 } },
]);
expect(id).toContain("#el");
const parsed = parseGsapScript(script);
const match = parsed.animations.find((a) => a.id === id);
expect(match).toBeDefined();
});

it("includes per-keyframe ease when provided", () => {
const { script } = addAnimationWithKeyframesToScript(BASE, "#el", 0, 1, [
{ percentage: 0, properties: { x: 0 }, ease: "power2.out" },
{ percentage: 100, properties: { x: 100 } },
]);
expect(script).toContain("power2.out");
});

it("returns original script on parse failure", () => {
const { script, id } = addAnimationWithKeyframesToScript("not valid js {{", "#el", 0, 1, [
{ percentage: 0, properties: { x: 0 } },
]);
expect(script).toBe("not valid js {{");
expect(id).toBe("");
});
});
Loading
Loading