feat(core): figma motion → GSAP translator + /figma skill v1#1869
Conversation
d094ae3 to
0460ce5
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
Review — feat(core): figma motion → GSAP translator + /figma skill v1
Verdict: LGTM with nits
Clean, well-structured translation pipeline. The three-layer architecture (motionEase → motionToGsap → emitTimelineScript) is the right decomposition — each layer has a single concern and tests verify the contracts between them. Type safety is strong throughout; the MappedEase discriminated union is a nice touch. The skill definition reads well and covers the two primary import paths (asset, motion) with clear steps.
Findings
1. PR description vs. actual repeat clamping (nit — docs only)
The PR body says "repeat: Infinity clamped to a finite computed loop count (ceil(compDuration/cycle)-1)" but clampRepeat() actually clamps Infinity → 0 (play once). The test confirms the "single play" intent. The description is misleading about the mechanism — worth fixing to match reality, or leaving a comment that the composition-duration-aware repeat count is deferred to a later PR. Not a code issue, just a description gap.
2. deriveId strips only a single leading # or . (nit)
selector.replace(/^[#.]/, "") strips one prefix character. A selector like #my.class becomes my.class → my-class → figma-my-class — fine. But ##double (unlikely but syntactically valid in HTML id context) becomes #double → -double → figma--double. Not a real-world concern, just noting the regex is intentionally minimal.
3. resolveStepEase deduplicates by identity, not by value (observation)
If two segments use the same bezier array [0.539, 0, 0.312, 0.995], they get two separate CustomEase.create calls (hfCe0, hfCe1) with identical curves. The test fixture has exactly this case (4-keyframe opacity track with two identical bezier segments) and creates 2 custom eases. Functionally correct — GSAP handles duplicate creates fine — but the emitted script is slightly larger than necessary. Low priority; mention only because the PR mentions "deterministic rendering" as a goal and duplicate eases add noise to the output.
4. buildSteps silently skips segments with undefined values (worth considering)
The if (tPrev === undefined || tCur === undefined || value === undefined) continue; guard silently drops segments that have holes. For well-formed MotionDoc inputs this never fires (the buildTween length check catches full mismatches), but a partial hole like values: [0, undefined, 1] would silently produce a 1-step tween instead of throwing. The buildTween validation checks values.length and times.length agree, but doesn't verify individual elements are defined. This is defensive coding and probably fine for v1, but worth a comment explaining the intent — is this "graceful degradation" or "should never happen"?
5. Barrel re-export changes export * → export type * (positive)
index.ts changing from export * from "./types" to export type * from "./types" is the right move — it ensures type-only re-exports don't create runtime dependencies. Clean.
6. parseFigmaRef.ts: .replace → .replaceAll (positive)
Good catch on the replace → replaceAll fix. The original replace("-", ":") only replaced the first hyphen; node IDs like 123-456-789 would have produced 123:456-789. replaceAll fixes this correctly.
7. Ease mapping coverage (observation)
The NAMED_EASE table covers linear, ease, easeIn/Out/InOut, easeInAndOut, backIn/Out/InOut, backInAndOut, and hold. Missing from what Figma Motion might emit: spring, anticipate, circIn/Out, bounceIn/Out. The fallback to "none" is safe but silent — a console.warn in the skill's runtime (not in the pure translator) could help agents notice when they're hitting the fallback. Not blocking since the skill doc explicitly says "if a track uses shader/spring/effect props with no GSAP mapping, bake instead."
8. Skill definition quality (positive)
skills/figma/SKILL.md is well-structured: clear auth prereq, routing decision tree, step-by-step for both asset and motion paths, determinism rules at the bottom. The "re-import guard: findByFigmaNode before re-fetching" note is a good idempotency hint for agents.
Ponytail
The code is already quite lean for what it does. The only candidate for reduction is the CustomEaseCounter interface — it's a single-field wrapper around a number used as a mutable ref. A plain { value: number } object literal without the named interface would save 4 lines (interface + JSDoc), but the named interface does improve readability at call sites. net: ~4 lines possible, not worth it. Lean already. Ship.
Stats
- 15 files changed, +359 / -14
- Core logic: ~150 lines across 3 new modules (motionEase, motionToGsap, emitTimelineScript)
- Tests: ~126 lines across 3 test files
- Skill definition: 40 lines
- Catalog/docs wiring: ~43 lines
— Review by Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 0460ce507b (stack member 2/6; sibling PRs #1868, #1870-#1873).
Note: body has 🤖 Claude Code footer + AI-trailer; HF convention flags this.
Summary — Adds a pure Figma Motion → GSAP timeline translator (motionToGsap, motionEase, emitTimelineScript) that emits a paused-timeline <script>, plus the /figma skill v1 for MCP-first routing. Structural translation matches the paused-GSAP contract; edge cases around infinite-repeat semantics and multi-selector docs are where the seams show.
Concerns
- 🟠 PR body contradicts implementation for
repeat: Infinity. Body says "repeat: Infinityclamped to a finite computed loop countceil(compDuration/cycle)-1" butmotionToGsap.ts:11-13clampRepeatreturns0(single play) forInfinity, and the test atmotionToGsap.test.ts:33documents "clamps Infinity -> 0 (single play) for determinism". Two very different behaviors — one preserves ~visual intent up to the composition duration, the other drops the loop entirely. Either the body is stale or the code is stale; pick one. The Headline fixture in tests declaresrepeat: Infinityand the runtime will play the fade-in-out once, silently, on every imported Figma Motion doc that used a looping animation. - 🟠 Unknown named ease silently falls back to
linearwith no signal.motionEase.ts:20returns{ kind: "named", ease: "none" }whenNAMED_EASE[key]misses (test atmotionEase.test.ts:24locks this in). Fixture uses "wobble" but Figma Motion has real eases the current dictionary doesn't cover (circIn,expoOut,elasticOut,bounce*,anticipate). A designer's tuned ease turns intolinearat import with no console warning, no return-channel to the caller — and the/figmaskill has no docs about which eases survive. Suggest either (a) returning{ kind: "unknown", original: ease }and lettingmotionToGsappush a warning into theTimelineSpecfor the caller to surface, or (b) covering the full motion.dev ease set at minimum with a documented list. - 🟠 Bezier array not validated to length 4.
mapEaseatmotionEase.ts:18doesif (Array.isArray(ease)) return { kind: "bezier", bezier: ease }— the TS type says[number, number, number, number]but that's not enforced at runtime. A malformed motion.dev payload (3 numbers, 5 numbers, NaN, non-numeric) rides through andemitTimelineScriptwrites a brokenCustomEase.create(…, "M0,0 C…")string that fails silently at load. Cheap fix:if (Array.isArray(ease) && ease.length === 4 && ease.every(n => Number.isFinite(n))); else fall through to"none"or throw. - 🟠 Skill doc says "after the GSAP + CustomEase CDN tags" but no runtime guard.
skills/figma/SKILL.md:35puts the load-order requirement in prose.emitTimelineScriptemitsCustomEase.create(…)andgsap.timeline(…)as bare calls at the top of the<script>; if either lib is missing (composition author forgot the CDN, or CustomEase isn't in their GSAP bundle) the whole<script>throws early andwindow.__timelines[…]is never registered — Studio's static analysis is happy (literal key present) but the timeline is silently absent at runtime. Suggest wrapping inif (typeof gsap === "undefined") { console.warn(…); return; }(or an IIFE) so failures surface.
Nits
- 🟡
parseFigmaRef.ts:6replace→replaceAllis a real bug fix (multi-hyphen node ids like1-2-3were silently truncating to1:2-3) but isn't called out in the PR body or a test. One-line assertion inparseFigmaRef.test.ts(normalizeNodeId("1-2-3") === "1:2:3") would lock it in. - 🟡
emitTweentl.set(selector, {…}, 0)fires per-tween unconditionally. For aMotionDocwith N tracks on the same selector, that's Ntl.setcalls at position 0. All safe (different properties, no clobber), but if a caller ever put two tracks on the same property (odd but not blocked by the type), the lasttl.setwins. Docstring onMotionDoc.trackscould note "one track per property". - 🟡
MotionDoc.selectoris per-doc, not per-track. Design choice — a doc represents one element with N property tracks. The/figmaskill's motion-import step 2 makes this implicit; worth calling out as an explicit constraint ("one MotionDoc per animated element") in the skill. - 🟡
repeat > 0emission asymmetry with GSAP. GSAP'srepeat: 0== play once (== omitting). Code correctly omitsrepeatwhent.repeat === 0, but emitsrepeat: 1when the doc hadrepeat: 1which in GSAP means "play twice". Motion.dev'srepeat: 1typically means "play once more, i.e. twice total" so this is likely right — but a comment noting the semantics alignment (motion.dev repeat count === GSAP repeat count, both zero-indexed on "extra plays") would save a future reader.
Questions
- ↩️ Since the doc footer says "superseded by the rewrite in #1873", is #1873's shape known now? If the /figma skill file at
skills/figma/SKILL.mdis going to be rewritten there anyway, is it worth spending review cycles on this version's routing rules, or is only the pure translator (motionToGsap+emitTimelineScript+motionEase) the load-bearing part of this PR? - ↩️ How is a
MotionDocproduced fromget_motion_contextMCP output? The skill file describes the shape verbally but there's nomotionContextToMotionDoc(...)helper in this PR — the agent is expected to hand-normalize. Any plan to add a parser, or is the contract deliberately loose so the agent can adapt to motion.dev version drift? - ↩️ The
Headlinefixture inmotionToGsap.test.tsmatches the PR body's "real payloads captured live" claim. Are those fixtures checked in anywhere for reproduction (e.g.packages/core/src/figma/__fixtures__/motion/)? Would help future maintainers verify against real payloads.
What I didn't verify
- End-to-end: did not run
hyperframes lint/validateagainst a generated composition or confirm the emitted script executes correctly under a real GSAP+CustomEase environment. - Whether Figma Motion's
get_motion_contextoutput actually presentstimesnormalized to0..1andease[]asvalues.length - 1array; taking the type comments at their word. - Studio static-analysis behavior for the
window.__timelines["figma-<slug>"]literal-key requirement — didn't cross-read Studio's regex/parser. - Did not scan #1873's diff to see whether/how it changes any of the above.
— Rames D Jusso
afc868a to
8478c60
Compare
0460ce5 to
e4dd754
Compare
|
Review feedback addressed (pushed in the absorbed update): Fixed
Answers
🤖 Generated with Claude Code |
8478c60 to
267815d
Compare
e4dd754 to
c36ece6
Compare
267815d to
9e0eabc
Compare
c36ece6 to
c37ca68
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 verification — reviewed at c37ca68a87a2bd0e82aa009cb229c2bf1791a729 (was 0460ce507b on R1).
Miga's R1 posted ~3 minutes before mine — LGTM-with-nits, findings partially overlap; layered here.
R1 findings — verification at HEAD
🟠 F1 — repeat: Infinity → 0 PR-body/code divergence → ✅ RESOLVED (by documentation)
motionToGsap.ts:11-18 — clampRepeat still returns 0 for Infinity. That's not a fix by code-change; it's a fix by making the intent unmisreadable, which per my feedback_blocker_resolved_by_intent_documentation rubric is a legitimate resolution shape. The JSDoc above clampRepeat now spells it out:
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).
PR body also updated to "repeat: Infinity clamped to 0 (single play) for determinism" — matches code. The contradiction R1 flagged (PR-body-said-X, code-does-Y) is gone; the semantics were always "single play" per the test docstring, and the body was the stale one. Downstream authors reading the module can no longer walk into the trap.
Sensible resolution — the alternative (composition-duration-aware loop count ceil(compDuration/cycle)-1) is genuinely non-trivial (needs compDuration threading through the translator) and belongs to a later milestone. The documented "single play" is a safe, deterministic default; the JSDoc explicitly names the deferred milestone so future readers know where the sharper answer will land.
Miga's #1 nit (same issue, different framing) → resolved by the same mechanism.
Cross-PR fix hoist — dash-parsing .replace → .replaceAll → ⚠️ CONFIRMED STILL LIVES HERE (not hoisted to #1868)
parseFigmaRef.ts:6 at this HEAD is raw.replaceAll("-", ":") (commit a2cc14b328cf "fix(core): use replaceAll for figma node-id dash-to-colon conversion"). The multi-segment regression test at parseFigmaRef.test.ts (converts every dash in a multi-segment node id, KEY:1-2-3 → 1:2:3) also lives here.
Per my R1 note and Miga's #6 positive callout: this fix touches a file that belongs to #1868. If #1868 merges to main before #1869 lands, main will have the buggy single-dash .replace in parseFigmaRef.ts until #1869 follows.
I've raised the same on the #1868 R2 — resolution is either (a) hoist the one-line + one-test to #1868, or (b) rely on Graphite merge_when_ready to land the stack in order. Either works. Flagging here so the two PRs' state stays coherent; no action needed on this PR beyond confirming the mechanism you pick on #1868.
Miga's R1 layering — remaining nits at HEAD
Miga's R1 verdict was LGTM-with-nits. Verifying the code-actionable ones at current HEAD:
- Miga #1 (PR body vs clamping) — ✅ resolved (see F1 above).
- Miga #2 (
deriveIdstrips only single#/.) — unchanged at HEAD (motionToGsap.ts:21). Genuine edge case (##double); non-blocking as Miga flagged. - Miga #3 (
resolveStepEasededup-by-identity not by value) — unchanged at HEAD; stillhfCe0/hfCe1per bezier occurrence. Non-blocking; deterministic-output goal is unaffected, script size cost only. - Miga #4 (
buildStepssilently skips undefined segments) — unchanged; stillcontinueon undefined atmotionToGsap.ts:53. Non-blocking for well-formedMotionDocinputs. - Miga #5 (
export type *) — positive callout, no action. - Miga #6 (dash
replaceAllfix) — same hoist question as my cross-PR observation. - Miga #7 (ease coverage gaps:
spring,circIn/Out,bounce*) — parallels my R1's finding #2. Unchanged at HEAD; documented in-skill as "if a track uses shader/spring/effect props with no GSAP mapping, bake instead." Acceptable v1 posture given the skill's escape hatch, but aconsole.warnfor silently-fell-through eases would help agents catch it. Not a blocker. - Miga #8 (skill doc quality) — positive.
My R1 concerns #2 (unknown-ease silent fallback), #3 (bezier length not validated), #4 (skill doc "after CDN tags" no runtime guard) — none code-changed at HEAD. Author's call whether v1 posture is acceptable to ship or whether they're worth a follow-up ticket. My read: shippable — the /figma skill's escape-hatch language covers the fallback-to-linear case, and the bezier validation is a defensive nice-to-have. If author wants to bank one, console.warn in a hardening pass is the highest-leverage cheap fix.
Cross-stack recheck
- Dash-parsing hoist is the only cross-stack coupling. Downstream #1870-#1873 all import through the barrel; nothing else re-implements parsing.
- The
MotionDoctypes andTimelineSpecshape stayed identical between R1 and R2 — no downstream API break.
AI trailer
Commits carry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> trailer + PR body ends with 🤖 Generated with Claude Code. HF convention strips these before merge — squash-strip covers it.
What I didn't verify
- Didn't rerun the fixture test suite locally — trusted the green
Testjob. - Didn't audit
emitTimelineScript's output against a real GSAP+CustomEase environment E2E (author's PR body claims a liveget_motion_contextpayload → lint + validate + render pass; I took that at their word). - Didn't cross-read #1873 to confirm the "superseded by /figma skill rewrite" note reshapes the skill routing — noted on R1 as a scoping question.
Verdict: LGTM. F1 resolved cleanly by intent-documentation. The dash-parsing hoist is a cross-PR bookkeeping question, not a blocker on either PR — both authors and the stack land order can close it.
— Rames D Jusso
miguel-heygen
left a comment
There was a problem hiding this comment.
LGTM. Stamping after Miga R2 verified the prior Figma-stack blockers are addressed; live checks are green, with only Graphite stack mergeability pending where applicable.
…manifest, asset snippet (#1868) ## What Foundations of the `@hyperframes/core/figma` module — the pure, transport-agnostic layer every later phase builds on: - **`types.ts`** — `FigmaRef`, `FigmaProvenance`, `FigmaManifestRecord`, and the Motion model (`MotionDoc`/`MotionTrack`/`TimelineSpec`/`GsapTween`) shared across the stack. - **`parseFigmaRef`** — normalizes any user input (full `/design|/file|/proto` URLs with `?node-id=1-2`, `fileKey:nodeId` shorthand, bare `fileKey`) into `{ fileKey, nodeId }`, including the URL-dash → API-colon node-id conversion. - **`freeze.ts`** — `freezeBytes`/`freezeUrl`/`freezeLocalFile` with a 256 MB cap; every Figma asset is frozen to a local file before it can reach a composition (determinism: no render-time network). - **`manifest.ts`** — the `.media/manifest.jsonl` ledger (same layout `media-use` writes, so a project has one shared media inventory without either skill depending on the other): append/read/find-by-node/next-id, with a pure type-guard (`isFigmaManifestRecord`) instead of `as`-casts. - **`assetSnippet.ts`** — manifest record → composition `<img>` snippet with escaped attrs + `data-figma-id`. - **publishConfig fix** — `./figma` added to `packages/core` `publishConfig.exports` (the packed-manifest CI gate requires every source export to have a dist mapping). ## Why Design spec: `docs/superpowers/specs/2026-06-30-figma-asset-integration-design.md`. These functions are deliberately transport-agnostic — when the project reversed from MCP-first to a REST/MCP split (spec §2), nothing in this layer changed. That was the point. ## Tests Unit tests per module (URL variants, freeze cap edges, manifest round-trip/malformed-line tolerance, snippet escaping). All colocated `*.test.ts`, vitest, no network. --- Stack (1/6): this PR → #1869 → #1870 → #1871 → #1872 → #1873 🤖 Generated with [Claude Code](https://claude.com/claude-code)
c37ca68 to
8f3ad15
Compare
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.
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>
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>
8f3ad15 to
8731fa2
Compare
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |

What
The headline capability's core: Figma Motion → editable GSAP timeline, plus the first version of the
/figmaskill.motionEase.ts— maps motion.dev ease values (named strings or[x1,y1,x2,y2]bezier arrays) to GSAP eases /CustomEaserefs.motionToGsap.ts—MotionDoc→TimelineSpec: one tween per (element, property) track, keyframe durations from time deltas, andrepeat: Infinityclamped to 0 (single play) for determinism — the runtime forbidsrepeat:-1; composition-duration-aware loop counts are a later milestone.emitTimelineScript.ts—TimelineSpec→ a composition<script>:gsap.timeline({ paused: true }),tl.setinitials +tl.to({ keyframes })steps,CustomEase.createpreamble, registered onwindow.__timelines["<literal-key>"](string literal — Studio static analysis requires it)./figmaskill v1 + catalog wiring (superseded by the rewrite in feat(skills): reroute /figma by capability - REST/CLI for phases 1-3, MCP for 4-5 (M4) #1873, kept here for honest history).Why
Figma Motion (Config 2026) is a timeline+keyframe model that maps ~1:1 onto HyperFrames' paused-GSAP contract — translation is structural, not interpretive. Spec §8 documents the exact property/easing mapping tables this implements.
Verified
Beyond unit tests (fixture MotionDoc → expected emitted script, literal-key/paused/finite assertions), this translator was exercised end-to-end against real
get_motion_contextpayloads captured live from a Figma file — the output composition passedhyperframes lint+validateand rendered correctly.Stack (2/6): #1868 → this PR → #1870 → #1871 → #1872 → #1873
🤖 Generated with Claude Code