diff --git a/packages/cli/src/utils/command-failure-tracking.test.ts b/packages/cli/src/utils/command-failure-tracking.test.ts index 1798d0ef2..0f7475af1 100644 --- a/packages/cli/src/utils/command-failure-tracking.test.ts +++ b/packages/cli/src/utils/command-failure-tracking.test.ts @@ -39,6 +39,60 @@ describe("trackCommandFailures", () => { expect(onFailure).not.toHaveBeenCalled(); }); + it("rejects an unknown flag on a leaf command", async () => { + const wrapped = trackCommandFailures( + () => + Promise.resolve({ + meta: { name: "leaf" }, + args: { out: { type: "string" } }, + run: () => Promise.resolve(), + } as CommandDef), + vi.fn(), + ); + const cmd = await wrapped(); + await expect( + (cmd.run as (ctx: unknown) => Promise)({ rawArgs: ["--bogus", "x"] }), + ).rejects.toThrow(/--bogus/); + }); + + it("skips the unknown-flag check when a command group delegates to a subcommand", async () => { + // `figma component --name x`: --name belongs to the subcommand's + // table; the group (subCommands + fallback-help run) must not reject it. + const run = vi.fn(() => Promise.resolve()); + const wrapped = trackCommandFailures( + () => + Promise.resolve({ + meta: { name: "figma" }, + subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) }, + run, + } as unknown as CommandDef), + vi.fn(), + ); + const cmd = await wrapped(); + await expect( + (cmd.run as (ctx: unknown) => Promise)({ + rawArgs: ["component", "KEY:1-2", "--name", "hero"], + }), + ).resolves.toBeUndefined(); + expect(run).toHaveBeenCalled(); + }); + + it("still rejects an unknown flag when the group is NOT delegating", async () => { + const wrapped = trackCommandFailures( + () => + Promise.resolve({ + meta: { name: "figma" }, + subCommands: { component: () => Promise.resolve({ meta: { name: "component" } }) }, + run: () => Promise.resolve(), + } as unknown as CommandDef), + vi.fn(), + ); + const cmd = await wrapped(); + await expect( + (cmd.run as (ctx: unknown) => Promise)({ rawArgs: ["--bogus"] }), + ).rejects.toThrow(/--bogus/); + }); + it("passes through a command with no run() untouched", async () => { const onFailure = vi.fn(); const parent: CommandDef = { meta: { name: "parent" } }; diff --git a/packages/cli/src/utils/command-failure-tracking.ts b/packages/cli/src/utils/command-failure-tracking.ts index d5bc1d435..efe65ef5e 100644 --- a/packages/cli/src/utils/command-failure-tracking.ts +++ b/packages/cli/src/utils/command-failure-tracking.ts @@ -54,7 +54,23 @@ function wrapCommand( // Reject unknown flags before the command body: citty silently ignores // them otherwise, dropping the value (e.g. `render --out x` fell back to // the default output path). Inside the try so the rejection is reported. - assertKnownFlags(cmd, ctx?.rawArgs ?? []); + // + // A command group (subCommands + fallback-help run) delegating to a + // subcommand must NOT assert here: the flags belong to the subcommand's + // table, not the group's, and would be falsely rejected (e.g. + // `figma component --name x`). The wrapped subcommand loaders + // below assert the leaf's own table, so typo protection is preserved. + // Heuristic caveat: "first non-dash token names a subcommand" is sound + // only while command groups declare no flags of their own — if a group + // grows a flag whose value could match a subcommand name, replace this + // with a real argv parse. + const rawArgs = ctx?.rawArgs ?? []; + const firstPositional = rawArgs.find((tok) => tok && !tok.startsWith("-")); + const delegatesToSub = + cmd.subCommands != null && + firstPositional != null && + Object.prototype.hasOwnProperty.call(cmd.subCommands, firstPositional); + if (!delegatesToSub) assertKnownFlags(cmd, rawArgs); return await run(ctx); } catch (err) { try { diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts index 9e9a0b552..1d8e00e17 100644 --- a/packages/core/src/figma/index.ts +++ b/packages/core/src/figma/index.ts @@ -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"; diff --git a/packages/core/src/figma/motionContextToDocs.test.ts b/packages/core/src/figma/motionContextToDocs.test.ts new file mode 100644 index 000000000..859a32fad --- /dev/null +++ b/packages/core/src/figma/motionContextToDocs.test.ts @@ -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: + '', + }, + }, + { + nodeId: "3021:6491", + nodeName: "3D Object - Headphones", + nodeType: "ROUNDED_RECTANGLE", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6492", + nodeName: "Shape", + nodeType: "ROUNDED_RECTANGLE", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6493", + nodeName: "Headline", + nodeType: "TEXT", + codeSnippets: { + motionDev: + '', + }, + }, + { + nodeId: "3021:6494", + nodeName: "Knob", + nodeType: "FRAME", + codeSnippets: { + motionDev: + '', + }, + }, + ], + timelineCohorts: [ + { + rootNodeId: "3021:6485", + durationMs: 2000, + loopMode: "loop", + memberNodeIds: ["3021:6487", "3021:6491", "3021:6492", "3021:6493", "3021:6494"], + }, + ], +}; + +const SELECTORS: Record = { + "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([]); + }); +}); diff --git a/packages/core/src/figma/motionContextToDocs.ts b/packages/core/src/figma/motionContextToDocs.ts new file mode 100644 index 000000000..7b07db9b4 --- /dev/null +++ b/packages/core/src/figma/motionContextToDocs.ts @@ -0,0 +1,229 @@ +/** + * 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 = { rotate: "rotation" }; + +/** Escape regex metacharacters — keys are `\w+` property names today, but a + * future caller passing anything else must not silently misparse. */ +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Extract the balanced `{...}` body following `marker` in `src`. Brace + * counting does not skip string literals — sound for motion.dev output + * (numeric arrays + named eases, never arbitrary strings containing `}`). */ +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(`${escapeRegExp(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(`${escapeRegExp(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, + times: number[], + ease: MotionEase[], + duration: number, +): { values: Array; 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; + 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; + 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; +} diff --git a/packages/core/src/figma/nodeToHtml.test.ts b/packages/core/src/figma/nodeToHtml.test.ts index 8c4b1f9b2..367311474 100644 --- a/packages/core/src/figma/nodeToHtml.test.ts +++ b/packages/core/src/figma/nodeToHtml.test.ts @@ -52,6 +52,56 @@ describe("nodeToHtml", () => { expect(out.html).toContain("background-color: #0066FF"); }); + it("positions nested children relative to their PARENT, not the root", () => { + const out = nodeToHtml( + frame([ + { + id: "1:2", + name: "Group", + type: "FRAME", + absoluteBoundingBox: BOX(600, 400, 300, 200), + children: [ + { + id: "1:3", + name: "Inner", + type: "RECTANGLE", + absoluteBoundingBox: BOX(620, 440, 100, 40), + fills: [SOLID_BLUE], + }, + ], + }, + ]), + { resolved: [], unresolved: [] }, + ); + // Group at canvas (600,400) inside root (100,200) → left 500, top 200. + expect(out.html).toContain("left: 500px"); + expect(out.html).toContain("top: 200px"); + // Inner at canvas (620,440) inside Group (600,400) → left 20, top 40 — + // NOT root-relative 520/240, which double-offsets when CSS resolves + // absolute position against the positioned parent. + expect(out.html).toContain("left: 20px"); + expect(out.html).toContain("top: 40px"); + expect(out.html).not.toContain("left: 520px"); + }); + + it("prefixes digit-leading slugs so ids stay CSS-selector-safe", () => { + const out = nodeToHtml( + frame([ + { + id: "1:2", + name: "3D Object - Headphones", + type: "RECTANGLE", + absoluteBoundingBox: BOX(120, 220, 100, 40), + fills: [SOLID_BLUE], + }, + ]), + { resolved: [], unresolved: [] }, + ); + // "#3d-object-headphones" would throw in querySelector/GSAP targeting + expect(out.html).toContain('id="n3d-object-headphones"'); + expect(out.html).not.toContain('id="3d-object-headphones"'); + }); + it("emits var() with literal fallback for resolved bindings", () => { const out = nodeToHtml( frame([ diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts index 102567833..393af42c4 100644 --- a/packages/core/src/figma/nodeToHtml.ts +++ b/packages/core/src/figma/nodeToHtml.ts @@ -162,7 +162,12 @@ interface RenderContext { } function uniqueSlug(ctx: RenderContext, name: string): string { - const base = slugify(name); + const raw = slugify(name); + // A digit-leading id ("3D Object" → "3d-object") is valid HTML but not a + // valid CSS selector — querySelector("#3d-object") throws, which breaks + // GSAP targeting and figma-motion translation. Prefix so ids stay + // selector-safe. + const base = /^[0-9]/.test(raw) ? `n${raw}` : raw; let slug = base; let n = 2; while (ctx.usedSlugs.has(slug)) slug = `${base}-${n++}`; @@ -186,7 +191,7 @@ function unresolvedAttr(node: FigmaNodeDocument, ctx: RenderContext): string { return ` data-figma-unresolved="${escapeHtml(props.join(" "))}"`; } -function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolean): string[] { +function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean): string[] { const box = boxOf(node); const styles: string[] = []; if (!box) return styles; @@ -197,10 +202,14 @@ function geometryCss(node: FigmaNodeDocument, ctx: RenderContext, isRoot: boolea `height: ${round(box.height)}px`, ); } else { + // CSS absolute positioning is relative to the nearest positioned + // ancestor — the PARENT's box, not the root origin. Subtracting the root + // for every depth double-offsets nested children (each level re-adds its + // ancestors' offsets), drifting content down-right and off-frame. styles.push( "position: absolute", - `left: ${round(box.x - ctx.origin.x)}px`, - `top: ${round(box.y - ctx.origin.y)}px`, + `left: ${round(box.x - parentBox.x)}px`, + `top: ${round(box.y - parentBox.y)}px`, `width: ${round(box.width)}px`, `height: ${round(box.height)}px`, ); @@ -243,10 +252,15 @@ function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] { // instead of a RangeError; real figma frames are nowhere near this deep. const MAX_DEPTH = 500; -function renderChildren(node: FigmaNodeDocument, ctx: RenderContext, depth: number): string { +function renderChildren( + node: FigmaNodeDocument, + ctx: RenderContext, + depth: number, + parentBox: Box, +): string { const childHtml: string[] = []; for (const child of childDocuments(node)) { - const rendered = renderNodeHtml(child, ctx, false, depth + 1); + const rendered = renderNodeHtml(child, ctx, false, depth + 1, parentBox); if (rendered.length > 0) childHtml.push(rendered); } return childHtml.length > 0 ? `\n${childHtml.join("\n")}\n` : ""; @@ -257,11 +271,12 @@ function renderNodeHtml( ctx: RenderContext, isRoot: boolean, depth = 0, + parentBox: Box = ctx.origin, ): string { if (node.visible === false || depth > MAX_DEPTH) return ""; const slug = uniqueSlug(ctx, node.name); const style = escapeHtml( - [...geometryCss(node, ctx, isRoot), ...decorationCss(node, ctx)].join("; "), + [...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "), ); // data-hf-snippet marks the file as a mountable fragment, not a standalone // composition — the project linter skips composition-root rules for it. @@ -278,7 +293,7 @@ function renderNodeHtml( return `
${text}
`; } - return `
${renderChildren(node, ctx, depth)}
`; + return `
${renderChildren(node, ctx, depth, boxOf(node) ?? parentBox)}
`; } export interface NodeToHtmlOptions { diff --git a/skills-manifest.json b/skills-manifest.json index 49bbd956b..95f09abfe 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -10,8 +10,8 @@ "files": 18 }, "figma": { - "hash": "ee1408c58b76db8e", - "files": 1 + "hash": "431580ce359639f5", + "files": 2 }, "general-video": { "hash": "e26710c3537b3a07", diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md index 3184b2c5a..d75e766b6 100644 --- a/skills/figma/SKILL.md +++ b/skills/figma/SKILL.md @@ -88,7 +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 (`#` from Phase-3 output or the authored scene). +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 --render --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 `