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;
}
+2 -2
View File
@@ -10,8 +10,8 @@
"files": 18
},
"figma": {
"hash": "17dd858344329586",
"files": 1
"hash": "e84abcb652194f57",
"files": 2
},
"general-video": {
"hash": "e26710c3537b3a07",
+2 -2
View File
@@ -88,8 +88,8 @@ Node tree → editable HTML at exact figma geometry, packaged as a registry item
No REST equivalent exists. You drive the MCP tools, then hand output to the pure helpers in `@hyperframes/core/figma`:
1. `get_motion_context(fileKey, nodeId)` — use `recursive:true` on the parent frame (one call for the whole scene, not one per element). Save the raw JSON next to the project (`.media/figma-cache/`) so retranslation is free.
2. Normalize into a `MotionDoc`: per animated property a `MotionTrack` { property (motion.dev name), values, times (0..1), ease[] (named or `[x1,y1,x2,y2]` bezier), duration, repeat }. Selector = the element's stable id (`#<id>` from Phase-3 output or the authored scene). **Translate values VERBATIM — never paraphrase, simplify, or invent keyframes.** Decoding rules (field-tested): the response carries two encodings — the motion.dev snippet is the timeline-cohort window (all tracks share the cohort duration), the CSS snippet may stretch per-track durations; use ONE encoding consistently (prefer motion.dev, cohort-windowed). Keyframes at times ≈0.9999→1 are **loop-wrap markers** (the instant reset at the loop boundary), NOT authored motion — drop them and realize the wrap via the tween's `repeat` restart; inventing a visible return/fade-out where the source has a wrap snap is the known failure mode.
2b. **Validate against ground truth before calling it done**: `export_video` on the cohort's `rootNodeId` gives Figma's own render of the timeline. Extract a frame grid from both videos at the same interval (e.g. `fps=5` contact sheets) and compare — element positions, fade states, and rotation phase must match frame-for-frame. A translation that hasn't been compared to the export is unverified.
2. Normalize into `MotionDoc`s with `motionContextToDocs(rawResponse, { selectorFor, repeat })` from `@hyperframes/core/figma`**never transcribe keyframe numbers by hand**. The helper encodes the field-tested decoding rules mechanically: it parses the motion.dev snippets (the reliable encoding — the CSS snippets stretch durations and can disagree; they are ignored), strips loop-wrap tail keyframes (sub-millisecond segments at times ≈0.9999→1 are the loop's instant reset, not authored motion — the wrap is realized by `repeat` restart), and preserves bezier eases verbatim. `selectorFor` must return the ids from the Phase-3 component import — don't derive selectors from node names.
2b. **Validate against ground truth before calling it done — mandatory**: `export_video` on the cohort's `rootNodeId` gives Figma's own render of the timeline. Run `node skills/figma/scripts/verify-motion.mjs --reference <export.mp4> --render <render.mp4> --crop WxH+X+Y` — it compares motion-energy deltas (static import fidelity cancels out) and fails below 15dB min motion-PSNR (calibrated: faithful ≈ 20+, diverging ≈ 5). Measure `--crop` from the render's actual card edges, don't guess. FAIL means re-check the translation, not the threshold.
3. `motionToGsap(doc)``emitTimelineScript(spec)` → inject as a `<script>` after the GSAP + CustomEase CDN tags. Paused, finite, registered on `window.__timelines` with a literal key.
4. Untranslatable track (shader-driven, unsupported prop, complex masks) → bake: `export_video` → freeze MP4 → embed as `<video class="clip">`. Exception: shader-driven tracks — figma's export path flattens shaders to the base color (see Shaders below), so a bake there silently loses the shader; ask the user for a native figma export instead. Always say which path you used and why. Named eases outside the mapped set fall back to linear — the mapping table lives in `motionEase.ts`; flag the fallback to the user when it fires.
5. Run `npx hyperframes lint && npx hyperframes validate` before calling it done.
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
/**
* Objective fidelity gate for figma-motion imports (skill step 2b).
*
* Compares the HyperFrames render against Figma's own `export_video` output
* using MOTION-ENERGY deltas: for each sample window [t, t+interval], the
* frame difference ref(t+i)-ref(t) is compared (PSNR) against
* render(t+i)-render(t). Static import divergence (fonts, rasterized edges,
* subpixel geometry — the hybrid-fidelity ceiling) cancels out of both
* deltas, so the score isolates choreography: trajectories, timing, easing.
*
* Calibration (SDS "Unlocked" card, 2026-07): a faithful translation scored
* min 20.3dB / mean 27.7dB; a diverging one (invented retract keyframes,
* wrong durations) scored min 5.0dB / mean 23.1dB. Default threshold 15dB
* sits between with margin on both sides.
*
* node verify-motion.mjs --reference figma-export.mp4 --render out.mp4 \
* [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]
*
* --crop selects the card region inside the (usually larger) composition
* frame. Measure it from the render (the card's left/top edge + scaled
* size), don't guess: a wrong crop reads as motion divergence.
*/
import { execFileSync, execSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
function arg(name, fallback) {
const i = process.argv.indexOf(`--${name}`);
return i > -1 ? process.argv[i + 1] : fallback;
}
const reference = arg("reference");
const render = arg("render");
if (!reference || !render) {
console.error(
"usage: verify-motion.mjs --reference ref.mp4 --render out.mp4 [--crop WxH+X+Y] [--interval 0.2] [--min-motion-psnr 15]",
);
process.exit(2);
}
const crop = arg("crop", null);
const interval = Number(arg("interval", "0.2"));
const minMotion = Number(arg("min-motion-psnr", "15"));
const ffprobe = (file) =>
Number(
execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file])
.toString()
.trim(),
);
const refDur = ffprobe(reference);
const renderDur = ffprobe(render);
const end = Math.min(refDur, renderDur) - interval - 0.01;
const dims = execFileSync("ffprobe", ["-v", "error", "-select_streams", "v", "-show_entries", "stream=width,height", "-of", "csv=p=0", reference])
.toString().trim().split(",").map(Number);
const [rw, rh] = dims;
let cropFilter = "";
if (crop) {
const m = crop.match(/^(\d+)x(\d+)\+(\d+)\+(\d+)$/);
if (!m) { console.error("bad --crop, expected WxH+X+Y"); process.exit(2); }
cropFilter = `crop=${m[1]}:${m[2]}:${m[3]}:${m[4]},`;
}
const dir = mkdtempSync(join(tmpdir(), "verify-motion-"));
const frame = (src, t, vf, dst) => {
const args = ["-y", "-v", "error", "-ss", String(t), "-i", src, "-frames:v", "1"];
if (vf) args.push("-vf", vf);
execFileSync("ffmpeg", args.concat(dst));
};
const diff = (a, b, dst) =>
execFileSync("ffmpeg", ["-y", "-v", "error", "-i", a, "-i", b, "-filter_complex", "blend=all_mode=difference", dst]);
const psnr = (a, b) => {
const err = execSync(`ffmpeg -i ${JSON.stringify(a)} -i ${JSON.stringify(b)} -lavfi psnr -f null - 2>&1`).toString();
const m = err.match(/average:([\d.]+|inf)/);
return m ? (m[1] === "inf" ? 99 : Number(m[1])) : NaN;
};
const renderVf = `${cropFilter}scale=${rw}:${rh}`;
const results = [];
for (let t = 0; t <= end; t = Math.round((t + interval) * 1000) / 1000) {
const t1 = Math.round((t + interval) * 1000) / 1000;
frame(reference, t, null, join(dir, "ra.png"));
frame(reference, t1, null, join(dir, "rb.png"));
frame(render, t, renderVf, join(dir, "oa.png"));
frame(render, t1, renderVf, join(dir, "ob.png"));
diff(join(dir, "ra.png"), join(dir, "rb.png"), join(dir, "rd.png"));
diff(join(dir, "oa.png"), join(dir, "ob.png"), join(dir, "od.png"));
results.push({ t, motion: psnr(join(dir, "rd.png"), join(dir, "od.png")), abs: psnr(join(dir, "rb.png"), join(dir, "ob.png")) });
}
rmSync(dir, { recursive: true, force: true });
const min = Math.min(...results.map((r) => r.motion));
const mean = results.reduce((s, r) => s + r.motion, 0) / results.length;
for (const r of results)
console.log(
`window ${r.t.toFixed(2)}s→${(r.t + interval).toFixed(2)}s motion-psnr=${r.motion.toFixed(2)}dB (abs=${r.abs.toFixed(1)}dB)${r.motion < minMotion ? " <-- BELOW THRESHOLD" : ""}`,
);
console.log(`\nwindows=${results.length} min-motion=${min.toFixed(2)}dB mean-motion=${mean.toFixed(2)}dB threshold=${minMotion}dB`);
if (min < minMotion) {
console.log("VERDICT: FAIL — choreography diverges from the Figma export (check timings, invented keyframes, durations)");
process.exit(1);
}
console.log("VERDICT: PASS — motion matches the Figma export within the static-fidelity ceiling");