From 5de4b6acd296c4dc4c3c8fa791741518b491fe5c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 1 Jul 2026 11:59:52 -0700 Subject: [PATCH 1/8] feat(core): add figma motion easing mapping --- packages/core/src/figma/motionEase.test.ts | 45 +++++++++++++++++++++ packages/core/src/figma/motionEase.ts | 47 ++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 packages/core/src/figma/motionEase.test.ts create mode 100644 packages/core/src/figma/motionEase.ts diff --git a/packages/core/src/figma/motionEase.test.ts b/packages/core/src/figma/motionEase.test.ts new file mode 100644 index 0000000000..0ca0d75eae --- /dev/null +++ b/packages/core/src/figma/motionEase.test.ts @@ -0,0 +1,45 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { mapEase } from "./motionEase"; + +describe("mapEase", () => { + it("maps linear to none", () => { + expect(mapEase("linear")).toEqual({ kind: "named", ease: "none" }); + }); + it("maps a bezier array through unchanged", () => { + expect(mapEase([0.539, 0, 0.312, 0.995])).toEqual({ + kind: "bezier", + bezier: [0.539, 0, 0.312, 0.995], + }); + }); + it("maps named eases to GSAP equivalents (case/format insensitive)", () => { + expect(mapEase("easeOut")).toEqual({ kind: "named", ease: "power2.out" }); + expect(mapEase("EASE_IN_AND_OUT")).toEqual({ + kind: "named", + ease: "power2.inOut", + }); + expect(mapEase("backOut")).toEqual({ kind: "named", ease: "back.out" }); + expect(mapEase("HOLD")).toEqual({ kind: "named", ease: "steps(1)" }); + }); + it("falls back to none for unknown named eases", () => { + expect(mapEase("wobble")).toEqual({ kind: "named", ease: "none" }); + }); +}); + +describe("mapEase validation + coverage", () => { + it("rejects malformed bezier arrays (wrong length / NaN) to linear", () => { + expect(mapEase([0.5, 0, 0.3] as unknown as [number, number, number, number])).toEqual({ + kind: "named", + ease: "none", + }); + expect(mapEase([0.5, Number.NaN, 0.3, 1])).toEqual({ kind: "named", ease: "none" }); + }); + it("covers circ/expo/bounce/elastic/anticipate/spring", () => { + expect(mapEase("circOut")).toEqual({ kind: "named", ease: "circ.out" }); + expect(mapEase("expoInOut")).toEqual({ kind: "named", ease: "expo.inOut" }); + expect(mapEase("bounceOut")).toEqual({ kind: "named", ease: "bounce.out" }); + expect(mapEase("elasticOut")).toEqual({ kind: "named", ease: "elastic.out" }); + expect(mapEase("anticipate")).toEqual({ kind: "named", ease: "back.in" }); + expect(mapEase("spring")).toEqual({ kind: "named", ease: "elastic.out" }); + }); +}); diff --git a/packages/core/src/figma/motionEase.ts b/packages/core/src/figma/motionEase.ts new file mode 100644 index 0000000000..5f52d6d1bd --- /dev/null +++ b/packages/core/src/figma/motionEase.ts @@ -0,0 +1,47 @@ +import type { MappedEase, MotionEase } from "./types"; + +// Full motion.dev named-ease coverage → nearest GSAP equivalent. Anything +// outside this table falls back to "none" (linear) — documented in the +// /figma skill's motion section so the fallback is never a surprise. +const NAMED_EASE: Record = { + linear: "none", + ease: "power1.inOut", + easein: "power2.in", + easeout: "power2.out", + easeinout: "power2.inOut", + easeinandout: "power2.inOut", + backin: "back.in", + backout: "back.out", + backinout: "back.inOut", + backinandout: "back.inOut", + circin: "circ.in", + circout: "circ.out", + circinout: "circ.inOut", + expoin: "expo.in", + expoout: "expo.out", + expoinout: "expo.inOut", + bouncein: "bounce.in", + bounceout: "bounce.out", + bounceinout: "bounce.inOut", + elasticin: "elastic.in", + elasticout: "elastic.out", + elasticinout: "elastic.inOut", + anticipate: "back.in", + spring: "elastic.out", + hold: "steps(1)", +}; + +function isBezier4(ease: unknown[]): ease is [number, number, number, number] { + return ease.length === 4 && ease.every((n) => typeof n === "number" && Number.isFinite(n)); +} + +export function mapEase(ease: MotionEase): MappedEase { + if (Array.isArray(ease)) { + // Runtime-validate the 4-tuple: a malformed payload (3 numbers, NaN) + // would otherwise emit a broken CustomEase path that fails at load. + if (isBezier4(ease)) return { kind: "bezier", bezier: ease }; + return { kind: "named", ease: "none" }; + } + const key = ease.toLowerCase().replace(/[_\s-]/g, ""); + return { kind: "named", ease: NAMED_EASE[key] ?? "none" }; +} From 259d859ebd29c415e05415cefa2c2bdadc852dae Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 1 Jul 2026 12:08:12 -0700 Subject: [PATCH 2/8] feat(core): translate figma motion doc to gsap timeline spec --- packages/core/src/figma/motionToGsap.test.ts | 61 +++++++++++++ packages/core/src/figma/motionToGsap.ts | 94 ++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 packages/core/src/figma/motionToGsap.test.ts create mode 100644 packages/core/src/figma/motionToGsap.ts diff --git a/packages/core/src/figma/motionToGsap.test.ts b/packages/core/src/figma/motionToGsap.test.ts new file mode 100644 index 0000000000..07ca8e7aa2 --- /dev/null +++ b/packages/core/src/figma/motionToGsap.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { motionToGsap } from "./motionToGsap"; +import type { MotionDoc } from "./types"; + +const headline: MotionDoc = { + selector: "#hero-headline", + tracks: [ + { + property: "opacity", + values: [0, 0, 1, 1, 0], + times: [0, 0.0686, 0.2273, 0.9999, 1], + ease: ["linear", [0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], + duration: 2, + repeat: Infinity, + }, + ], +}; + +describe("motionToGsap", () => { + it("derives a finite, paused-timeline spec from the captured Headline payload", () => { + const spec = motionToGsap(headline); + expect(spec.timelineId).toBe("figma-hero-headline"); + expect(spec.tweens).toHaveLength(1); + + const t = spec.tweens[0]; + expect(t?.selector).toBe("#hero-headline"); + expect(t?.property).toBe("opacity"); + expect(t?.initial).toBe(0); + // 4 segments for 5 keyframes + expect(t?.steps).toHaveLength(4); + // clamps Infinity -> 0 (single play) for determinism + expect(t?.repeat).toBe(0); + }); + + it("computes per-segment durations from times * duration", () => { + const t = motionToGsap(headline).tweens[0]; + // segment 0: (0.0686 - 0) * 2 = 0.1372 + expect(t?.steps[0]?.duration).toBeCloseTo(0.1372, 4); + // segment 1: (0.2273 - 0.0686) * 2 = 0.3174 + expect(t?.steps[1]?.duration).toBeCloseTo(0.3174, 4); + }); + + it("registers a CustomEase per bezier segment and names it in the step", () => { + const spec = motionToGsap(headline); + expect(spec.customEases).toHaveLength(2); + expect(spec.customEases[0]?.bezier).toEqual([0.539, 0, 0.312, 0.995]); + // step 0 ease is linear -> none; step 1 ease is the first bezier + expect(spec.tweens[0]?.steps[0]?.ease).toBe("none"); + expect(spec.tweens[0]?.steps[1]?.ease).toBe(spec.customEases[0]?.name); + }); + + it("throws when times and values lengths disagree", () => { + expect(() => + motionToGsap({ + selector: "#x", + tracks: [{ property: "x", values: [0, 1], times: [0], ease: ["linear"], duration: 1 }], + }), + ).toThrow(); + }); +}); diff --git a/packages/core/src/figma/motionToGsap.ts b/packages/core/src/figma/motionToGsap.ts new file mode 100644 index 0000000000..214cf4942c --- /dev/null +++ b/packages/core/src/figma/motionToGsap.ts @@ -0,0 +1,94 @@ +import { mapEase } from "./motionEase"; +import type { + CustomEaseRef, + GsapKeyframeStep, + GsapTween, + MotionDoc, + MotionTrack, + TimelineSpec, +} from "./types"; + +/** + * repeat semantics match GSAP and motion.dev: count of EXTRA plays + * (0 = play once). Infinity clamps to 0 — a single play — because a + * deterministic render needs a finite timeline; composition-duration-aware + * loop counts are a later milestone (spec §6 motion notes). + */ +function clampRepeat(repeat: number | undefined): number { + return repeat !== undefined && Number.isFinite(repeat) && repeat > 0 ? Math.floor(repeat) : 0; +} + +function deriveId(selector: string): string { + const base = selector.replace(/^[#.]/, "").replace(/[^A-Za-z0-9_-]/g, "-"); + return `figma-${base.length > 0 ? base : "timeline"}`; +} + +/** Mutable counter shared across all tracks so generated CustomEase names stay unique. */ +interface CustomEaseCounter { + value: number; +} + +/** Resolves one segment's ease, registering a CustomEase in `customEases` for bezier arrays. */ +function resolveStepEase( + rawEase: string | [number, number, number, number], + customEases: CustomEaseRef[], + counter: CustomEaseCounter, +): string { + const mapped = mapEase(rawEase); + if (mapped.kind === "bezier") { + const name = `hfCe${counter.value}`; + counter.value += 1; + customEases.push({ name, bezier: mapped.bezier }); + return name; + } + return mapped.ease; +} + +function buildSteps( + track: MotionTrack, + customEases: CustomEaseRef[], + counter: CustomEaseCounter, +): GsapKeyframeStep[] { + const steps: GsapKeyframeStep[] = []; + + for (let i = 1; i < track.values.length; i += 1) { + const tPrev = track.times[i - 1]; + const tCur = track.times[i]; + const value = track.values[i]; + if (tPrev === undefined || tCur === undefined || value === undefined) continue; + + const rawEase = track.ease[i - 1] ?? "linear"; + const ease = resolveStepEase(rawEase, customEases, counter); + steps.push({ value, duration: (tCur - tPrev) * track.duration, ease }); + } + + return steps; +} + +function buildTween( + track: MotionTrack, + selector: string, + customEases: CustomEaseRef[], + counter: CustomEaseCounter, +): GsapTween { + if (track.values.length < 2 || track.times.length !== track.values.length) { + throw new Error(`motionToGsap: invalid track "${track.property}" (values/times mismatch)`); + } + const initial = track.values[0]; + if (initial === undefined) throw new Error(`motionToGsap: empty track "${track.property}"`); + + return { + selector, + property: track.property, + initial, + steps: buildSteps(track, customEases, counter), + repeat: clampRepeat(track.repeat), + }; +} + +export function motionToGsap(doc: MotionDoc): TimelineSpec { + const customEases: CustomEaseRef[] = []; + const counter: CustomEaseCounter = { value: 0 }; + const tweens = doc.tracks.map((track) => buildTween(track, doc.selector, customEases, counter)); + return { timelineId: deriveId(doc.selector), tweens, customEases }; +} From 81e8a0172755727b523d70f022722f3c8c4a4e8d Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 1 Jul 2026 12:14:24 -0700 Subject: [PATCH 3/8] feat(core): emit paused GSAP timeline script from figma motion spec --- .fallowrc.jsonc | 11 ++-- .../core/src/figma/emitTimelineScript.test.ts | 48 +++++++++++++++++ packages/core/src/figma/emitTimelineScript.ts | 53 +++++++++++++++++++ packages/core/src/figma/index.ts | 24 ++++++++- 4 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/figma/emitTimelineScript.test.ts create mode 100644 packages/core/src/figma/emitTimelineScript.ts diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 75d0064561..a0130b782f 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -202,15 +202,16 @@ "file": "packages/studio/src/utils/timelineElementSplit.ts", "exports": ["buildPatchTarget", "readFileContent"], }, - // freezeUrl and freezeLocalFile are public API for Task 4 manifest flow - // and the /figma skill integration; not yet imported by current code. + // freezeUrl and freezeLocalFile are public API re-exported from the figma + // barrel (index.ts) for Task 4 manifest flow and the /figma skill integration; + // not yet imported by current code outside the module. { "file": "packages/core/src/figma/freeze.ts", "exports": ["freezeUrl", "freezeLocalFile"], }, - // mediaDir, typeDirPath, isFigmaManifestRecord: exported from manifest.ts - // for barrel wiring in Task 8 (one-batch export across Tasks 2-7). - // Not yet consumed by any code in the current stack. + // mediaDir, typeDirPath, isFigmaManifestRecord: re-exported from the figma + // barrel (index.ts) via manifest.ts per Task 8 wiring. Consumed only by the + // /figma skill integration, not by code in the current codebase. { "file": "packages/core/src/figma/manifest.ts", "exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"], diff --git a/packages/core/src/figma/emitTimelineScript.test.ts b/packages/core/src/figma/emitTimelineScript.test.ts new file mode 100644 index 0000000000..c9a24c60f0 --- /dev/null +++ b/packages/core/src/figma/emitTimelineScript.test.ts @@ -0,0 +1,48 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; +import { emitTimelineScript } from "./emitTimelineScript"; +import { motionToGsap } from "./motionToGsap"; +import type { MotionDoc } from "./types"; + +const doc: MotionDoc = { + selector: "#hero-headline", + tracks: [ + { + property: "opacity", + values: [0, 1, 0], + times: [0, 0.5, 1], + ease: ["linear", [0.539, 0, 0.312, 0.995]], + duration: 2, + repeat: Infinity, + }, + ], +}; + +describe("emitTimelineScript", () => { + const script = emitTimelineScript(motionToGsap(doc)); + + it("creates a paused timeline and never emits repeat:-1", () => { + expect(script).toContain("gsap.timeline({ paused: true })"); + expect(script).not.toContain("repeat: -1"); + }); + it("registers under a string-literal __timelines key", () => { + expect(script).toContain('window.__timelines["figma-hero-headline"] = tl;'); + }); + it("uses string-literal selectors and sets the initial value", () => { + expect(script).toContain('tl.set("#hero-headline", { opacity: 0 }, 0);'); + expect(script).toContain('tl.to("#hero-headline", { keyframes: ['); + }); + it("registers a CustomEase for the bezier segment", () => { + expect(script).toContain('CustomEase.create("hfCe0", "M0,0 C0.539,0 0.312,0.995 1,1");'); + }); +}); + +describe("emitTimelineScript runtime guard", () => { + it("wraps the script in an IIFE that warns when gsap/CustomEase are missing", () => { + const script = emitTimelineScript(motionToGsap(doc)); + expect(script).toContain('typeof gsap === "undefined"'); + expect(script).toContain("console.warn"); + expect(script.startsWith("(function () {")).toBe(true); + expect(script.endsWith("})();")).toBe(true); + }); +}); diff --git a/packages/core/src/figma/emitTimelineScript.ts b/packages/core/src/figma/emitTimelineScript.ts new file mode 100644 index 0000000000..9ec93d4ec1 --- /dev/null +++ b/packages/core/src/figma/emitTimelineScript.ts @@ -0,0 +1,53 @@ +import type { GsapTween, TimelineSpec } from "./types"; + +function lit(value: string): string { + return JSON.stringify(value); +} + +function num(value: number): number { + return Math.round(value * 1e6) / 1e6; +} + +function val(value: number | string): string { + return typeof value === "number" ? String(num(value)) : JSON.stringify(value); +} + +function emitTween(t: GsapTween): string[] { + const set = `tl.set(${lit(t.selector)}, { ${t.property}: ${val(t.initial)} }, 0);`; + const kf = t.steps + .map( + (s) => + `{ ${t.property}: ${val(s.value)}, duration: ${num(s.duration)}, ease: ${lit(s.ease)} }`, + ) + .join(", "); + const repeat = t.repeat > 0 ? `, repeat: ${t.repeat}` : ""; + return [set, `tl.to(${lit(t.selector)}, { keyframes: [${kf}]${repeat} }, 0);`]; +} + +export function emitTimelineScript(spec: TimelineSpec): string { + const lines: string[] = []; + // Guard the whole script: if the composition author forgot the GSAP or + // CustomEase CDN tag, warn loudly instead of throwing mid-script and + // silently never registering the timeline. + lines.push("(function () {"); + const needsCustomEase = spec.customEases.length > 0; + const missing = needsCustomEase + ? 'typeof gsap === "undefined" || typeof CustomEase === "undefined"' + : 'typeof gsap === "undefined"'; + const libs = needsCustomEase ? "gsap + CustomEase" : "gsap"; + lines.push( + `if (${missing}) { console.warn(${lit(`figma timeline ${spec.timelineId}: ${libs} not loaded — add the CDN