feat(core): figma module foundations — types, parseFigmaRef, freeze, 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)
This commit is contained in:
Vance Ingalls
2026-07-03 14:12:24 -07:00
committed by GitHub
parent 34590649a0
commit 397c3ba7a8
13 changed files with 585 additions and 0 deletions
+24
View File
@@ -202,6 +202,19 @@
"file": "packages/studio/src/utils/timelineElementSplit.ts", "file": "packages/studio/src/utils/timelineElementSplit.ts",
"exports": ["buildPatchTarget", "readFileContent"], "exports": ["buildPatchTarget", "readFileContent"],
}, },
// freezeUrl and freezeLocalFile are public API for Task 4 manifest flow
// and the /figma skill integration; not yet imported by current code.
{
"file": "packages/core/src/figma/freeze.ts",
"exports": ["freezeUrl", "freezeLocalFile"],
},
// mediaDir, typeDirPath, isFigmaManifestRecord: exported from manifest.ts
// for barrel wiring in Task 8 (one-batch export across Tasks 2-7).
// Not yet consumed by any code in the current stack.
{
"file": "packages/core/src/figma/manifest.ts",
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
},
], ],
"ignoreDependencies": [ "ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`, // Runtime/dynamic deps not visible to static analysis: tsup `external`,
@@ -325,6 +338,12 @@
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so // → assert-remove-spawn shape; each verifies a distinct prune behavior, so
// extracting the shared scaffold would obscure what each case asserts. // extracting the shared scaffold would obscure what each case asserts.
"packages/cli/src/commands/skills.test.ts", "packages/cli/src/commands/skills.test.ts",
// figma test files: freeze.test.ts and manifest.test.ts share a common
// arrange/act/assert setup preamble (file creation + test dir handling) that
// is minimal boilerplate; collapsing it into a shared fixture would reduce
// readability of each test's independent setup.
"packages/core/src/figma/freeze.test.ts",
"packages/core/src/figma/manifest.test.ts",
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit // layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
// rule (overflow / overlap / occlusion) that each install their own mocked // rule (overflow / overlap / occlusion) that each install their own mocked
// geometry + computed-style before asserting. The clone groups are this // geometry + computed-style before asserting. The clone groups are this
@@ -447,6 +466,11 @@
// label-position code added earlier in the file shifted its line number, // label-position code added earlier in the file shifted its line number,
// so the line-shift fingerprint re-flags this inherited finding. // so the line-shift fingerprint re-flags this inherited finding.
"packages/parsers/src/gsapParserAcorn.ts", "packages/parsers/src/gsapParserAcorn.ts",
// isFigmaManifestRecord: type guard with 19 cyclomatic due to chained
// field validation (id/type/path/source types + value shape guards).
// This is the correct shape for a discriminating type guard; refactoring
// into smaller guards would obscure the unified validation contract.
"packages/core/src/figma/manifest.ts",
], ],
}, },
} }
+10
View File
@@ -203,6 +203,12 @@
"import": "./src/fonts/systemFontLocator.ts", "import": "./src/fonts/systemFontLocator.ts",
"types": "./src/fonts/systemFontLocator.ts" "types": "./src/fonts/systemFontLocator.ts"
}, },
"./figma": {
"bun": "./src/figma/index.ts",
"node": "./dist/figma/index.js",
"import": "./src/figma/index.ts",
"types": "./src/figma/index.ts"
},
"./schemas/registry.json": "./schemas/registry.json", "./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json" "./schemas/registry-item.json": "./schemas/registry-item.json"
}, },
@@ -226,6 +232,10 @@
"import": "./dist/editing/affordances.js", "import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts" "types": "./dist/editing/affordances.d.ts"
}, },
"./figma": {
"import": "./dist/figma/index.js",
"types": "./dist/figma/index.d.ts"
},
"./generators": { "./generators": {
"import": "./dist/generators/hyperframes.js", "import": "./dist/generators/hyperframes.js",
"types": "./dist/generators/hyperframes.d.ts" "types": "./dist/generators/hyperframes.d.ts"
@@ -0,0 +1,28 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildAssetSnippet } from "./assetSnippet";
import type { FigmaManifestRecord } from "./types";
const record: FigmaManifestRecord = {
id: "image_001",
type: "image",
path: ".media/images/image_001.png",
source: "figma",
description: 'Hero "banner"',
width: 240,
height: 57,
provenance: { source: "figma", fileKey: "FK", nodeId: "92:573", format: "png" },
};
describe("buildAssetSnippet", () => {
it("emits an img tag with src, dims, escaped alt, and data-figma-id", () => {
const { path, html } = buildAssetSnippet(record);
expect(path).toBe(".media/images/image_001.png");
expect(html).toContain('src=".media/images/image_001.png"');
expect(html).toContain('width="240"');
expect(html).toContain('height="57"');
expect(html).toContain('data-figma-id="92:573"');
expect(html).toContain("&quot;banner&quot;");
expect(html).not.toContain('"banner"');
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { AssetSnippet, FigmaManifestRecord } from "./types";
function escapeAttr(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
export function buildAssetSnippet(record: FigmaManifestRecord): AssetSnippet {
// Every interpolated attribute is escaped — path and nodeId are
// system-generated today, but defense-in-depth is free here.
const alt = escapeAttr(record.description ?? record.id);
const src = escapeAttr(record.path);
const w = record.width !== undefined ? ` width="${record.width}"` : "";
const h = record.height !== undefined ? ` height="${record.height}"` : "";
const html = `<img src="${src}" alt="${alt}"${w}${h} data-figma-id="${escapeAttr(record.provenance.nodeId)}" />`;
return { path: record.path, html };
}
+58
View File
@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { exceedsFreezeCap, freezeBytes, MAX_FREEZE_BYTES } from "./freeze";
const dirs: string[] = [];
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), "hf-freeze-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
describe("exceedsFreezeCap", () => {
it("is false at and under the cap, true just over it", () => {
expect(exceedsFreezeCap(MAX_FREEZE_BYTES)).toBe(false);
expect(exceedsFreezeCap(MAX_FREEZE_BYTES + 1)).toBe(true);
});
});
describe("freezeBytes", () => {
it("writes bytes to a nested path and returns the length", () => {
const dest = join(scratch(), "images", "a.png");
const bytes = new Uint8Array([1, 2, 3, 4]);
expect(freezeBytes(bytes, dest)).toBe(4);
expect(Array.from(readFileSync(dest))).toEqual([1, 2, 3, 4]);
});
it("throws on empty bytes", () => {
expect(() => freezeBytes(new Uint8Array(0), join(scratch(), "x"))).toThrow();
});
});
describe("freezeUrl allowlist", () => {
it("accepts figma + figma-s3 hosts, https only", async () => {
const { isAllowedFreezeUrl } = await import("./freeze");
expect(isAllowedFreezeUrl("https://s3-alpha-sig.figma.com/img/x")).toBe(true);
expect(isAllowedFreezeUrl("https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/x")).toBe(
true,
);
expect(isAllowedFreezeUrl("http://s3-alpha-sig.figma.com/img/x")).toBe(false);
expect(isAllowedFreezeUrl("https://169.254.169.254/latest/meta-data/")).toBe(false);
expect(isAllowedFreezeUrl("https://evilfigma.com/x")).toBe(false);
expect(isAllowedFreezeUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedFreezeUrl("not a url")).toBe(false);
});
it("refuses to freeze from a non-allowlisted url", async () => {
const { freezeUrl } = await import("./freeze");
await expect(freezeUrl("http://localhost:6379/x", "/tmp/never")).rejects.toThrow(
/refusing non-figma url/,
);
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* "Freeze" = write asset bytes to local disk permanently so renders never
* re-fetch from figma (design spec §5) — not Object.freeze.
*/
import { copyFileSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
// ponytail: bound the write so a hostile/runaway source can't fill the disk.
export const MAX_FREEZE_BYTES = 256 * 1024 * 1024;
export function exceedsFreezeCap(byteLength: number): boolean {
return byteLength > MAX_FREEZE_BYTES;
}
export function freezeBytes(bytes: Uint8Array, destPath: string): number {
if (bytes.length === 0) throw new Error("freeze failed: empty bytes");
if (exceedsFreezeCap(bytes.length))
throw new Error(`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
// Exclusive create; on EEXIST remove and retry — never write through an
// existing file or planted symlink (CodeQL js/insecure-temporary-file).
try {
writeFileSync(destPath, bytes, { flag: "wx" });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
rmSync(destPath);
writeFileSync(destPath, bytes, { flag: "wx" });
}
return bytes.length;
}
/**
* Only figma-owned hosts may be frozen from a URL — render/CDN responses
* come from figma.com subdomains or figma's S3 buckets. Blocks SSRF via a
* crafted manifest/config URL (metadata endpoints, internal services).
*/
export function isAllowedFreezeUrl(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname;
return host === "figma.com" || host.endsWith(".figma.com") || host.endsWith(".amazonaws.com");
}
export async function freezeUrl(url: string, destPath: string): Promise<number> {
if (!isAllowedFreezeUrl(url))
throw new Error(`freeze failed: refusing non-figma url ${url} (https + figma hosts only)`);
const res = await fetch(url);
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status}`);
const declared = Number(res.headers.get("content-length") ?? 0);
if (exceedsFreezeCap(declared))
throw new Error(`freeze failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`);
return freezeBytes(new Uint8Array(await res.arrayBuffer()), destPath);
}
export function freezeLocalFile(srcPath: string, destPath: string): void {
const size = statSync(srcPath).size;
if (exceedsFreezeCap(size))
throw new Error(`freeze failed: ${size} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
+9
View File
@@ -0,0 +1,9 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { MAX_FREEZE_BYTES } from "./index";
describe("figma barrel", () => {
it("re-exports the freeze cap constant", () => {
expect(MAX_FREEZE_BYTES).toBe(256 * 1024 * 1024);
});
});
+2
View File
@@ -0,0 +1,2 @@
export * from "./types";
export { MAX_FREEZE_BYTES } from "./freeze";
+93
View File
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { appendRecord, findByFigmaNode, manifestPath, nextId, readManifest } from "./manifest";
import type { FigmaManifestRecord } from "./types";
const dirs: string[] = [];
function project(): string {
const d = mkdtempSync(join(tmpdir(), "hf-manifest-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
function rec(id: string, nodeId: string): FigmaManifestRecord {
return {
id,
type: "image",
path: `.media/images/${id}.png`,
source: "figma",
provenance: { source: "figma", fileKey: "FK", nodeId, format: "png" },
};
}
describe("manifest", () => {
it("appends and reads back records", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendRecord(p, rec("image_002", "3:4"));
const all = readManifest(p);
expect(all.map((r) => r.id)).toEqual(["image_001", "image_002"]);
expect(all[1]?.provenance.nodeId).toBe("3:4");
});
it("finds a record by figma node", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
expect(findByFigmaNode(p, "FK", "1:2")?.id).toBe("image_001");
expect(findByFigmaNode(p, "FK", "9:9")).toBeNull();
});
it("allocates incrementing ids per type", () => {
const p = project();
expect(nextId(p, "image")).toBe("image_001");
appendRecord(p, rec("image_001", "1:2"));
expect(nextId(p, "image")).toBe("image_002");
});
it("skips a manifest line that doesn't match the record shape", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendFileSync(manifestPath(p), JSON.stringify({ foo: "bar" }) + "\n");
appendRecord(p, rec("image_002", "3:4"));
expect(readManifest(p).map((r) => r.id)).toEqual(["image_001", "image_002"]);
});
it("nextId scans other writers' rows (media-use) so ids never collide", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
// media-use shaped row: no provenance.source — fails the figma guard
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "image_007",
type: "image",
path: ".media/images/image_007.png",
source: "unsplash",
provenance: { provider: "unsplash" },
}) + "\n",
);
expect(nextId(p, "image")).toBe("image_008");
});
it("rejects manifest rows with a non image/video type", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "audio_001",
type: "audio",
path: "x",
source: "figma:F/1",
provenance: { source: "figma", fileKey: "F", nodeId: "1:1" },
}) + "\n",
);
expect(readManifest(p)).toHaveLength(0);
});
});
+123
View File
@@ -0,0 +1,123 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { FigmaManifestRecord } from "./types";
const MANIFEST_FILE = "manifest.jsonl";
// Mirrors the media-use `.media/` layout for interop (one shared inventory).
const TYPE_DIRS: Record<FigmaManifestRecord["type"], string> = {
image: "images",
video: "video",
};
export function mediaDir(projectDir: string): string {
return join(projectDir, ".media");
}
export function manifestPath(projectDir: string): string {
return join(mediaDir(projectDir), MANIFEST_FILE);
}
export function typeDirPath(projectDir: string, type: FigmaManifestRecord["type"]): string {
return join(mediaDir(projectDir), TYPE_DIRS[type]);
}
/**
* Guards figma-OWNED rows only — media-use rows (bgm/sfx/icon, no
* provenance.source) legitimately fail this and are filtered out of
* figma's read-view. The shared file is the inventory; each writer
* reads its own rows. `nextId` is the one cross-writer concern and
* scans the full file.
*/
export function isFigmaManifestRecord(value: unknown): value is FigmaManifestRecord {
if (typeof value !== "object" || value === null) return false;
if (!("id" in value) || !("type" in value) || !("path" in value) || !("source" in value))
return false;
if (
typeof value.id !== "string" ||
(value.type !== "image" && value.type !== "video") ||
typeof value.path !== "string"
)
return false;
if (typeof value.source !== "string") return false;
if (!("provenance" in value)) return false;
const provenance = value.provenance;
if (typeof provenance !== "object" || provenance === null) return false;
if (!("source" in provenance) || !("fileKey" in provenance) || !("nodeId" in provenance))
return false;
return (
provenance.source === "figma" &&
typeof provenance.fileKey === "string" &&
typeof provenance.nodeId === "string"
);
}
export function readManifest(projectDir: string): FigmaManifestRecord[] {
const p = manifestPath(projectDir);
if (!existsSync(p)) return [];
const out: FigmaManifestRecord[] = [];
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isFigmaManifestRecord(parsed)) out.push(parsed);
} catch {
// ponytail: skip malformed/non-matching lines, don't crash the whole read
}
}
return out;
}
export function appendRecord(projectDir: string, record: FigmaManifestRecord): void {
mkdirSync(typeDirPath(projectDir, record.type), { recursive: true });
appendFileSync(manifestPath(projectDir), JSON.stringify(record) + "\n");
}
export function findByFigmaNode(
projectDir: string,
fileKey: string,
nodeId: string,
): FigmaManifestRecord | null {
for (const r of readManifest(projectDir)) {
if (
r.provenance.source === "figma" &&
r.provenance.fileKey === fileKey &&
r.provenance.nodeId === nodeId
)
return r;
}
return null;
}
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
const re = new RegExp(`^${type}_(\\d+)$`);
let max = 0;
// Scan EVERY writer's rows (media-use included), not just figma-owned ones —
// ids and file paths are shared across the inventory, so a figma id minted
// from a figma-only view would collide with media-use's image_NNN files.
const p = manifestPath(projectDir);
if (!existsSync(p)) return `${type}_001`;
for (const line of readFileSync(p, "utf8").split(/\r?\n/)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
continue;
}
if (
typeof parsed !== "object" ||
parsed === null ||
!("id" in parsed) ||
typeof parsed.id !== "string"
)
continue;
const m = parsed.id.match(re);
const n = m?.[1];
if (n !== undefined) max = Math.max(max, Number.parseInt(n, 10));
}
return `${type}_${String(max + 1).padStart(3, "0")}`;
}
@@ -0,0 +1,31 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { parseFigmaRef } from "./parseFigmaRef";
describe("parseFigmaRef", () => {
it("extracts fileKey + nodeId from a /design/ URL and converts node-id dashes to colons", () => {
const ref = parseFigmaRef(
"https://www.figma.com/design/JjiZQGiUKqbkPCs3sviEUF/Playground?node-id=92-573&t=x",
);
expect(ref.fileKey).toBe("JjiZQGiUKqbkPCs3sviEUF");
expect(ref.nodeId).toBe("92:573");
});
it("handles /file/ URLs without a node-id", () => {
const ref = parseFigmaRef("https://figma.com/file/ABC123/Name");
expect(ref.fileKey).toBe("ABC123");
expect(ref.nodeId).toBeUndefined();
});
it("accepts fileKey:nodeId shorthand", () => {
expect(parseFigmaRef("ABC123:1-2")).toEqual({ fileKey: "ABC123", nodeId: "1:2" });
});
it("accepts a bare fileKey", () => {
expect(parseFigmaRef("ABC123")).toEqual({ fileKey: "ABC123" });
});
it("throws on empty input", () => {
expect(() => parseFigmaRef(" ")).toThrow();
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { FigmaRef } from "./types";
const FILE_KEY_RE = /\/(?:design|file|proto)\/([A-Za-z0-9]+)/;
function normalizeNodeId(raw: string): string {
return raw.replace("-", ":");
}
export function parseFigmaRef(input: string): FigmaRef {
const trimmed = input.trim();
if (trimmed.length === 0) throw new Error("parseFigmaRef: empty input");
if (!trimmed.includes("/")) {
const colon = trimmed.indexOf(":");
if (colon === -1) return { fileKey: trimmed };
const fileKey = trimmed.slice(0, colon);
const node = trimmed.slice(colon + 1);
if (fileKey.length === 0) throw new Error(`parseFigmaRef: invalid ref "${input}"`);
return node.length > 0 ? { fileKey, nodeId: normalizeNodeId(node) } : { fileKey };
}
const keyMatch = trimmed.match(FILE_KEY_RE);
const fileKey = keyMatch?.[1];
if (fileKey === undefined) throw new Error(`parseFigmaRef: no fileKey in "${input}"`);
const q = trimmed.indexOf("?");
if (q !== -1) {
const raw = new URLSearchParams(trimmed.slice(q + 1)).get("node-id");
if (raw !== null && raw.length > 0) return { fileKey, nodeId: normalizeNodeId(raw) };
}
return { fileKey };
}
+88
View File
@@ -0,0 +1,88 @@
export interface FigmaRef {
fileKey: string;
nodeId?: string;
}
export type FigmaAssetFormat = "png" | "svg" | "jpg" | "pdf";
export interface FigmaProvenance {
source: "figma";
fileKey: string;
nodeId: string;
version?: string;
format?: FigmaAssetFormat;
scale?: number;
}
export interface FigmaManifestRecord {
id: string;
type: "image" | "video";
path: string;
source: string;
description?: string;
width?: number;
height?: number;
provenance: FigmaProvenance;
}
export interface AssetSnippet {
path: string;
html: string;
}
/** A motion.dev easing value as returned by get_motion_context: a named ease
* (e.g. "linear") or a cubic-bezier control-point array [x1,y1,x2,y2]. */
export type MotionEase = string | [number, number, number, number];
/** One animated property, normalized from get_motion_context's motion.dev snippet. */
export interface MotionTrack {
/** motion.dev property name: "opacity" | "x" | "y" | "scaleX" | "scaleY" | "rotation" | ... */
property: string;
values: Array<number | string>;
/** normalized 0..1, same length as values */
times: number[];
/** length values.length - 1; ease[i] governs the segment values[i] -> values[i+1] */
ease: MotionEase[];
/** seconds */
duration: number;
/** Infinity or a finite count; clamped to finite during translation */
repeat?: number;
}
export interface MotionDoc {
/** string-literal CSS selector for the target element, e.g. "#hero-title" */
selector: string;
tracks: MotionTrack[];
}
export type MappedEase =
| { kind: "named"; ease: string }
| { kind: "bezier"; bezier: [number, number, number, number] };
export interface CustomEaseRef {
name: string;
bezier: [number, number, number, number];
}
export interface GsapKeyframeStep {
value: number | string;
/** seconds */
duration: number;
/** GSAP ease string or a registered CustomEase name */
ease: string;
}
export interface GsapTween {
selector: string;
property: string;
initial: number | string;
steps: GsapKeyframeStep[];
/** finite; 0 = play once */
repeat: number;
}
export interface TimelineSpec {
timelineId: string;
tweens: GsapTween[];
customEases: CustomEaseRef[];
}