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 = `
`;
+ 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[];
+}