feat(core,figma-skill): mechanical motion translation + objective fidelity gate

Two guarantees so figma-motion imports can't drift from the design again:

- motionContextToDocs(): raw get_motion_context response -> MotionDoc[],
  in code. Parses the motion.dev snippets (the reliable encoding; the CSS
  snippets stretch durations and can disagree), strips loop-wrap tail
  keyframes (sub-ms segments at the window end are the loop reset, not
  authored motion), preserves bezier eases verbatim. Fixture test uses the
  verbatim response from a real Motion timeline whose translation was
  frame-validated against Figma's own export_video render.

- skills/figma/scripts/verify-motion.mjs: mandatory post-render gate.
  Compares motion-energy deltas between the render and the export_video
  ground truth so static import fidelity cancels out and the score
  isolates choreography. Calibrated on a faithful translation (min 20.3dB)
  vs a diverging one (min 5.0dB); threshold 15dB.

The skill's Motion step now routes through both: no hand transcription,
no unverified completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 00:49:07 -07:00
co-authored by Claude Fable 5
parent 44653e186a
commit a2243f7586
6 changed files with 480 additions and 4 deletions
+6
View File
@@ -59,5 +59,11 @@ export type {
TokensToVariablesResult,
} from "./tokensToVariables";
export { mapEase } from "./motionEase";
export {
motionContextToDocs,
type MotionContextResponse,
type MotionContextNode,
type MotionContextToDocsOptions,
} from "./motionContextToDocs";
export { motionToGsap } from "./motionToGsap";
export { emitTimelineScript } from "./emitTimelineScript";
@@ -0,0 +1,144 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { motionContextToDocs } from "./motionContextToDocs";
import type { MotionContextResponse } from "./motionContextToDocs";
/**
* Fixture: verbatim `get_motion_context` response for the SDS "Unlocked"
* Motion card (fileKey Hl5L3gkQ3Tz3Y2KTQJbAkT, node 3021:6485, 2026-07-08).
* The expected outputs were validated frame-by-frame against Figma's own
* `export_video` render of the same timeline (verify-motion.mjs PASS).
*/
const FIXTURE: MotionContextResponse = {
nodes: [
{
nodeId: "3021:6487",
nodeName: "Shape w offset",
nodeType: "FRAME",
codeSnippets: {
motionDev:
'<motion.div initial={{ rotate: 0, }} animate={{ rotate: [0, 223.149, 360], }} transition={{ rotate: { duration: 2, times: [0, 0.9999, 1], ease: "linear", repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6491",
nodeName: "3D Object - Headphones",
nodeType: "ROUNDED_RECTANGLE",
codeSnippets: {
motionDev:
'<motion.div initial={{ y: 0, }} animate={{ y: [0, -71.246, -64.253, -19.227, -1.212, 0], }} transition={{ y: { duration: 2, times: [0, 0.8066, 0.9997, 0.9998, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", "linear", "linear", "linear"], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6492",
nodeName: "Shape",
nodeType: "ROUNDED_RECTANGLE",
codeSnippets: {
motionDev:
'<motion.div initial={{ width: 160.5, }} animate={{ width: [160.5, 500, 500, 160.5], }} transition={{ width: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6493",
nodeName: "Headline",
nodeType: "TEXT",
codeSnippets: {
motionDev:
'<motion.div initial={{ opacity: 0, x: 98.914, }} animate={{ opacity: [0, 0, 1, 1, 0], x: [98.914, 98.914, 0, 0, 98.914], }} transition={{ opacity: { duration: 2, 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]], repeat: Infinity }, x: { duration: 2, 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]], repeat: Infinity }, }} />',
},
},
{
nodeId: "3021:6494",
nodeName: "Knob",
nodeType: "FRAME",
codeSnippets: {
motionDev:
'<motion.div initial={{ x: -339.087, }} animate={{ x: [-339.087, 0, 0, -339.087], }} transition={{ x: { duration: 2, times: [0, 0.1956, 0.9999, 1], ease: [[0.539, 0, 0.312, 0.995], "linear", [0.539, 0, 0.312, 0.995]], repeat: Infinity }, }} />',
},
},
],
timelineCohorts: [
{
rootNodeId: "3021:6485",
durationMs: 2000,
loopMode: "loop",
memberNodeIds: ["3021:6487", "3021:6491", "3021:6492", "3021:6493", "3021:6494"],
},
],
};
const SELECTORS: Record<string, string> = {
"3021:6487": "#shape-w-offset",
"3021:6491": "#headphones-3d",
"3021:6492": "#shape-2",
"3021:6493": "#headline",
"3021:6494": "#knob",
};
function docs(repeat = 1) {
return motionContextToDocs(FIXTURE, {
selectorFor: (n) => SELECTORS[n.nodeId] ?? `#${n.nodeId}`,
repeat,
});
}
describe("motionContextToDocs", () => {
it("produces one doc per animated node with caller-supplied selectors", () => {
const out = docs();
expect(out.map((d) => d.selector)).toEqual([
"#shape-w-offset",
"#headphones-3d",
"#shape-2",
"#headline",
"#knob",
]);
});
it("strips the loop-wrap tail and extends the last keyframe to the window end", () => {
const rotation = docs()[0]?.tracks[0];
// [0, 223.149, 360] @ [0, .9999, 1]: the 360 is the wrap marker.
// 223.149° over the 2s window is the true angular speed (the CSS
// snippet's "360° in 2s" disagrees and is wrong — verified against
// export_video ground truth).
expect(rotation?.property).toBe("rotation");
expect(rotation?.values).toEqual([0, 223.149]);
expect(rotation?.times).toEqual([0, 1]);
});
it("strips multi-keyframe wrap clusters but keeps real sub-second motion", () => {
const y = docs()[1]?.tracks[0];
// tail cluster (-19.227, -1.212, 0) spans <1ms each — wrap markers.
// -64.253 @ 0.9997 ends a 0.386s segment — real, kept, extended to 1.
expect(y?.values).toEqual([0, -71.246, -64.253]);
expect(y?.times).toEqual([0, 0.8066, 1]);
});
it("keeps hold segments and drops only the wrap snap", () => {
const width = docs()[2]?.tracks[0];
expect(width?.values).toEqual([160.5, 500, 500]);
expect(width?.times).toEqual([0, 0.1956, 1]);
});
it("parses multiple properties per node", () => {
const headline = docs()[3];
expect(headline?.tracks.map((t) => t.property).sort()).toEqual(["opacity", "x"]);
const opacity = headline?.tracks.find((t) => t.property === "opacity");
expect(opacity?.values).toEqual([0, 0, 1, 1]);
expect(opacity?.times).toEqual([0, 0.0686, 0.2273, 1]);
});
it("preserves bezier easing arrays and applies the requested repeat", () => {
const knob = docs(2)[4]?.tracks[0];
expect(knob?.ease[0]).toEqual([0.539, 0, 0.312, 0.995]);
expect(knob?.repeat).toBe(2);
expect(knob?.duration).toBe(2);
});
it("skips nodes without motion.dev snippets", () => {
const out = motionContextToDocs(
{ nodes: [{ nodeId: "1:1", nodeName: "Static", codeSnippets: { css: "..." } }] },
{ selectorFor: () => "#static" },
);
expect(out).toEqual([]);
});
});
@@ -0,0 +1,221 @@
/**
* Mechanical translation of a raw Figma MCP `get_motion_context` response
* into MotionDocs — no hand transcription (design spec §6 motion notes).
*
* Field-tested decoding rules (2026-07, SDS "Unlocked" card):
*
* - The response carries two encodings per node. The motion.dev snippet is
* the reliable one: every track is sampled inside the timeline-cohort
* window, so values are correct AT their normalized times. The CSS
* snippet stretches per-track durations and can disagree — it is ignored.
* - Keyframes clustered at the tail of the window (segments spanning less
* than WRAP_EPSILON_S of real time) are LOOP-WRAP MARKERS — the instant
* reset at the loop boundary — not authored motion. They are stripped and
* the wrap is realized by the tween's `repeat` restart. Inventing visible
* returns from wrap markers is the known failure mode this module exists
* to prevent.
* - After stripping, the last kept keyframe's time extends to 1 so the
* track fills its window (the dropped tail spanned sub-millisecond time).
*
* Verification is still mandatory: render and compare against
* `export_video` ground truth with skills/figma/scripts/verify-motion.mjs.
*/
import type { MotionDoc, MotionEase, MotionTrack } from "./types";
/** Tail segments shorter than this (seconds) are loop-wrap markers. */
const WRAP_EPSILON_S = 0.005;
export interface MotionContextNode {
nodeId: string;
nodeName: string;
nodeType?: string;
codeSnippets?: { css?: string; motionDev?: string };
}
export interface MotionContextResponse {
nodes: MotionContextNode[];
timelineCohorts?: Array<{
rootNodeId: string;
durationMs: number;
loopMode?: string;
memberNodeIds?: string[];
}>;
}
export interface MotionContextToDocsOptions {
/**
* Maps a node to the CSS selector of its imported element. REQUIRED in
* practice: pass the ids from the Phase-3 component import (the mapper's
* slugs, e.g. `#headphones-3d`) — deriving selectors from node names here
* would silently drift from the imported HTML.
*/
selectorFor: (node: MotionContextNode) => string;
/** Extra plays per track (GSAP semantics: 0 = play once). Default 0. */
repeat?: number;
}
/** motion.dev property → GSAP property. */
const PROPERTY_MAP: Record<string, string> = { rotate: "rotation" };
/** Extract the balanced `{...}` body following `marker` in `src`. */
function balancedBlock(src: string, marker: string): string | null {
const at = src.indexOf(marker);
if (at === -1) return null;
const start = src.indexOf("{", at + marker.length - 1);
if (start === -1) return null;
let depth = 0;
for (let i = start; i < src.length; i += 1) {
if (src[i] === "{") depth += 1;
if (src[i] === "}") {
depth -= 1;
if (depth === 0) return src.slice(start + 1, i);
}
}
return null;
}
/** Extract a balanced `[...]` immediately after `key:` inside `src`. */
function arrayAfterKey(src: string, key: string): string | null {
const re = new RegExp(`${key}\\s*:\\s*\\[`);
const m = re.exec(src);
if (!m) return null;
const start = m.index + m[0].length - 1;
let depth = 0;
for (let i = start; i < src.length; i += 1) {
if (src[i] === "[") depth += 1;
if (src[i] === "]") {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
return null;
}
function scalarAfterKey(src: string, key: string): string | null {
const m = new RegExp(`${key}\\s*:\\s*("[^"]*"|[\\w.]+)`).exec(src);
return m?.[1] ?? null;
}
function parseEase(transitionBlock: string): MotionEase[] | null {
const arr = arrayAfterKey(transitionBlock, "ease");
if (arr) return JSON.parse(arr) as MotionEase[];
const scalar = scalarAfterKey(transitionBlock, "ease");
if (scalar?.startsWith('"')) return [JSON.parse(scalar) as string];
return null;
}
/**
* Strip loop-wrap tail keyframes: walking from the end, drop keyframes whose
* incoming segment spans < WRAP_EPSILON_S of real time; stop at the first
* substantial segment. Extend the last kept time to 1.
*/
function stripWrapTail(
values: Array<number | string>,
times: number[],
ease: MotionEase[],
duration: number,
): { values: Array<number | string>; times: number[]; ease: MotionEase[] } {
let end = values.length;
while (end > 2) {
const tPrev = times[end - 2];
const tCur = times[end - 1];
if (tPrev === undefined || tCur === undefined) break;
if ((tCur - tPrev) * duration >= WRAP_EPSILON_S) break;
end -= 1;
}
const v = values.slice(0, end);
const t = times.slice(0, end);
const e = ease.slice(0, Math.max(1, end - 1));
const last = t.length - 1;
if (t[last] !== undefined && t[last] < 1) t[last] = 1;
return { values: v, times: t, ease: e };
}
interface RawTrackData {
values: Array<number | string>;
times: number[];
ease: MotionEase[];
duration: number;
}
/** Extract and validate one property's raw arrays from the snippet blocks. */
function extractTrackData(animate: string, transition: string, prop: string): RawTrackData | null {
const valuesSrc = arrayAfterKey(animate, prop);
const propTransition = balancedBlock(transition, `${prop}: {`);
if (!valuesSrc || !propTransition) return null;
const timesSrc = arrayAfterKey(propTransition, "times");
const durationSrc = scalarAfterKey(propTransition, "duration");
const ease = parseEase(propTransition);
if (!timesSrc || !durationSrc || !ease) return null;
const values = JSON.parse(valuesSrc) as Array<number | string>;
const times = JSON.parse(timesSrc) as number[];
const duration = Number(durationSrc);
if (values.length !== times.length || !Number.isFinite(duration)) return null;
return { values, times, ease, duration };
}
/** Parse one property's track out of the animate/transition blocks. */
function parsePropertyTrack(
animate: string,
transition: string,
prop: string,
repeat: number,
): MotionTrack | null {
const raw = extractTrackData(animate, transition, prop);
if (!raw) return null;
// segment eases: a single named ease applies to every segment
const segCount = raw.values.length - 1;
const segEase =
raw.ease.length === segCount
? raw.ease
: Array.from({ length: segCount }, (_, i) => raw.ease[i % raw.ease.length] ?? "linear");
const stripped = stripWrapTail(raw.values, raw.times, segEase, raw.duration);
return {
property: PROPERTY_MAP[prop] ?? prop,
values: stripped.values,
times: stripped.times,
ease: stripped.ease,
duration: raw.duration,
repeat,
};
}
/** Parse one node's motion.dev snippet into MotionTracks. */
function parseNodeTracks(node: MotionContextNode, repeat: number): MotionTrack[] {
const snippet = node.codeSnippets?.motionDev;
if (!snippet) return [];
const animate = balancedBlock(snippet, "animate={");
const transition = balancedBlock(snippet, "transition={");
if (!animate || !transition) return [];
const tracks: MotionTrack[] = [];
const propRe = /(\w+)\s*:\s*\[/g;
let m: RegExpExecArray | null;
while ((m = propRe.exec(animate)) !== null) {
const prop = m[1];
if (prop === undefined) continue;
const track = parsePropertyTrack(animate, transition, prop, repeat);
if (track) tracks.push(track);
}
return tracks;
}
/**
* Raw `get_motion_context` response → MotionDoc[], mechanically. Feed the
* result to motionToGsap/emitTimelineScript; then verify against
* export_video ground truth before calling the import done.
*/
export function motionContextToDocs(
response: MotionContextResponse,
options: MotionContextToDocsOptions,
): MotionDoc[] {
const repeat = options.repeat ?? 0;
const docs: MotionDoc[] = [];
for (const node of response.nodes ?? []) {
const tracks = parseNodeTracks(node, repeat);
if (tracks.length === 0) continue;
docs.push({ selector: options.selectorFor(node), tracks });
}
return docs;
}