feat(core): figma motion → GSAP translator + /figma skill v1 (#1869)

* feat(core): add figma motion easing mapping

* feat(core): translate figma motion doc to gsap timeline spec

* feat(core): emit paused GSAP timeline script from figma motion spec

* fix(core): restore type exports dropped from figma barrel in Task 8

* feat(skills): add /figma import skill + catalog wiring

Add the agent-facing /figma skill (asset + Figma Motion import via the
Figma MCP connector, built on @hyperframes/core/figma) and wire it into
the skill catalog across CLAUDE.md, README.md, docs/guides/skills.mdx,
and the hyperframes router's capability map. Bumps the skill count from
19 to 20 in CLAUDE.md and README.md.

* fix(core): use replaceAll for figma node-id dash-to-colon conversion

* style: format skills catalog tables

oxfmt-align the README and router SKILL.md tables after the /figma +
/hyperframes-keyframes merge left uneven column padding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cli): add missing cache fields to telemetry test fixture

ExtractionPhaseBreakdown gained cachePublishFailures/cacheGcEvictions/
cacheGcBytesFreed/cacheAgedPartialsCleared; the studioRenderTelemetry
test fixture was never updated, breaking Typecheck on main and every PR
based on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-03 15:45:07 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 1a7002f208
commit e92700acde
17 changed files with 442 additions and 14 deletions
@@ -109,6 +109,10 @@ const fullPerf: RenderPerfSummary = {
extractMs: 60,
cacheHits: 3,
cacheMisses: 4,
cachePublishFailures: 0,
cacheGcEvictions: 0,
cacheGcBytesFreed: 0,
cacheAgedPartialsCleared: 0,
},
tmpPeakBytes: 1024,
captureAvgMs: 13,
@@ -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);
});
});
@@ -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 <script> tags before this one`)}); return; }`,
);
for (const ce of spec.customEases) {
const [x1, y1, x2, y2] = ce.bezier;
lines.push(
`CustomEase.create(${lit(ce.name)}, "M0,0 C${num(x1)},${num(y1)} ${num(x2)},${num(y2)} 1,1");`,
);
}
lines.push("const tl = gsap.timeline({ paused: true });");
for (const t of spec.tweens) lines.push(...emitTween(t));
lines.push("window.__timelines = window.__timelines || {};");
lines.push(`window.__timelines[${lit(spec.timelineId)}] = tl;`);
lines.push("})();");
return lines.join("\n");
}
+23 -2
View File
@@ -1,2 +1,23 @@
export * from "./types";
export { MAX_FREEZE_BYTES } from "./freeze";
export type * from "./types";
export { parseFigmaRef } from "./parseFigmaRef";
export {
MAX_FREEZE_BYTES,
exceedsFreezeCap,
freezeBytes,
freezeUrl,
freezeLocalFile,
} from "./freeze";
export {
mediaDir,
manifestPath,
typeDirPath,
isFigmaManifestRecord,
readManifest,
appendRecord,
findByFigmaNode,
nextId,
} from "./manifest";
export { buildAssetSnippet } from "./assetSnippet";
export { mapEase } from "./motionEase";
export { motionToGsap } from "./motionToGsap";
export { emitTimelineScript } from "./emitTimelineScript";
@@ -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" });
});
});
+47
View File
@@ -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<string, string> = {
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" };
}
@@ -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();
});
});
+94
View File
@@ -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 };
}
@@ -29,3 +29,9 @@ describe("parseFigmaRef", () => {
expect(() => parseFigmaRef(" ")).toThrow();
});
});
describe("normalizeNodeId multi-segment", () => {
it("converts every dash in a multi-segment node id", () => {
expect(parseFigmaRef("KEY:1-2-3").nodeId).toBe("1:2:3");
});
});
+1 -1
View File
@@ -3,7 +3,7 @@ import type { FigmaRef } from "./types";
const FILE_KEY_RE = /\/(?:design|file|proto)\/([A-Za-z0-9]+)/;
function normalizeNodeId(raw: string): string {
return raw.replace("-", ":");
return raw.replaceAll("-", ":");
}
export function parseFigmaRef(input: string): FigmaRef {