diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index f384a14b44..75d0064561 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -202,6 +202,19 @@ "file": "packages/studio/src/utils/timelineElementSplit.ts", "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": [ // 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 // extracting the shared scaffold would obscure what each case asserts. "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 // rule (overflow / overlap / occlusion) that each install their own mocked // 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, // so the line-shift fingerprint re-flags this inherited finding. "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", ], }, } diff --git a/packages/core/package.json b/packages/core/package.json index 3edf14a666..9cb397530c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -203,6 +203,12 @@ "import": "./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-item.json": "./schemas/registry-item.json" }, @@ -226,6 +232,10 @@ "import": "./dist/editing/affordances.js", "types": "./dist/editing/affordances.d.ts" }, + "./figma": { + "import": "./dist/figma/index.js", + "types": "./dist/figma/index.d.ts" + }, "./generators": { "import": "./dist/generators/hyperframes.js", "types": "./dist/generators/hyperframes.d.ts" diff --git a/packages/core/src/figma/assetSnippet.test.ts b/packages/core/src/figma/assetSnippet.test.ts new file mode 100644 index 0000000000..d6d38634fc --- /dev/null +++ b/packages/core/src/figma/assetSnippet.test.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(""banner""); + expect(html).not.toContain('"banner"'); + }); +}); diff --git a/packages/core/src/figma/assetSnippet.ts b/packages/core/src/figma/assetSnippet.ts new file mode 100644 index 0000000000..17c620b414 --- /dev/null +++ b/packages/core/src/figma/assetSnippet.ts @@ -0,0 +1,20 @@ +import type { AssetSnippet, FigmaManifestRecord } from "./types"; + +function escapeAttr(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +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 = `${alt}`; + return { path: record.path, html }; +} diff --git a/packages/core/src/figma/freeze.test.ts b/packages/core/src/figma/freeze.test.ts new file mode 100644 index 0000000000..4ae994b79f --- /dev/null +++ b/packages/core/src/figma/freeze.test.ts @@ -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/, + ); + }); +}); diff --git a/packages/core/src/figma/freeze.ts b/packages/core/src/figma/freeze.ts new file mode 100644 index 0000000000..996758a6ff --- /dev/null +++ b/packages/core/src/figma/freeze.ts @@ -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 { + 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); +} diff --git a/packages/core/src/figma/index.test.ts b/packages/core/src/figma/index.test.ts new file mode 100644 index 0000000000..3ee653b665 --- /dev/null +++ b/packages/core/src/figma/index.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts new file mode 100644 index 0000000000..8c301ca922 --- /dev/null +++ b/packages/core/src/figma/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export { MAX_FREEZE_BYTES } from "./freeze"; diff --git a/packages/core/src/figma/manifest.test.ts b/packages/core/src/figma/manifest.test.ts new file mode 100644 index 0000000000..6820bfe215 --- /dev/null +++ b/packages/core/src/figma/manifest.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/figma/manifest.ts b/packages/core/src/figma/manifest.ts new file mode 100644 index 0000000000..c2180764ba --- /dev/null +++ b/packages/core/src/figma/manifest.ts @@ -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 = { + 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")}`; +} diff --git a/packages/core/src/figma/parseFigmaRef.test.ts b/packages/core/src/figma/parseFigmaRef.test.ts new file mode 100644 index 0000000000..43973d0f0d --- /dev/null +++ b/packages/core/src/figma/parseFigmaRef.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/figma/parseFigmaRef.ts b/packages/core/src/figma/parseFigmaRef.ts new file mode 100644 index 0000000000..0b06d0cf78 --- /dev/null +++ b/packages/core/src/figma/parseFigmaRef.ts @@ -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 }; +} diff --git a/packages/core/src/figma/types.ts b/packages/core/src/figma/types.ts new file mode 100644 index 0000000000..769106235b --- /dev/null +++ b/packages/core/src/figma/types.ts @@ -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; + /** 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[]; +}