Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions templates/design/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,12 @@ ladder.
`figmeta.selectedNodeData`; `import-figma-clipboard` uses those before any
heuristic matching and supports multi-selection. Clipboard metadata is not a
public Figma contract, so a copied frame link remains the stable exact path
if Figma changes that field. Without a token, current Figma's binary-only
clipboard has no browser-readable HTML fallback; give setup guidance instead
of claiming a successful import.
if Figma changes that field. Without a token, `import-figma-clipboard` falls
back to a local Kiwi binary decode: geometry, auto-layout, text, solid fills,
and strokes are editable immediately; image fills become annotated placeholders
that can be filled in later with `hydrate-figma-paste-images` once a Figma
access token is connected. This is not a full-fidelity import — report which
image fills are still unresolved and offer to hydrate them.
- For "what's in this Figma file/frame?" or "show me a screenshot of this
frame" without importing anything, use `get-figma-design-context` — no
`nodeId` lists pages/top-level frames (like the official Figma MCP's
Expand Down
2 changes: 1 addition & 1 deletion templates/design/FIGMA_INTEROPERABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ product must report them instead of claiming success.
| Figma frame URL / file key | Reads the exact node through `file_content:read`, converts it to a new Design screen, mirrors expiring images into durable storage, and returns a per-node fidelity report. | Mixed; see node matrix below. | REST fixture, authenticated file, screenshot comparison. |
| Figma URL without a node id | Imports the first top-level object on the first page. A specific frame URL is recommended for deterministic results. | Same as node import. | Multi-page and empty-page fixtures. |
| Figma branch URL | Uses the branch key and imports that branch's node. | Same as node import. | Main/branch pair with divergent content. |
| Figma clipboard to Design | Uses private `figmeta.selectedNodeData` ids when present, then the same REST converter. Older visible HTML is only a fallback. Binary-only clipboard data without ids/token is not decoded. | Exact selection identity while Figma's private metadata shape remains compatible; node fidelity is mixed. | Real Chrome copy from single, multi, nested, and 100+ node selections. |
| Figma clipboard to Design | Uses private `figmeta.selectedNodeData` ids when present, then the same REST converter. With a token: full fidelity matching `import-figma-frame`. Without a token: local Kiwi binary decode — geometry, auto-layout, text, solid fills, and strokes are editable; image fills are stamped with `data-figma-image-ref` placeholders and can be resolved later by connecting a token (`hydrate-figma-paste-images` action). | Exact selection identity while Figma's private metadata shape remains compatible; node fidelity is mixed. No-token imports resolve images retroactively on demand. | Real Chrome copy from single, multi, nested, and 100+ node selections; token-less copy followed by deferred token connect and verify image resolution. |
| `.fig` upload | Bounded best-effort decoding of known Kiwi/ZIP variants into editable HTML. Embedded images are moved to durable storage. | Experimental. The format is proprietary and has no compatibility guarantee. | Corpus of real files from multiple Figma versions; never only generated containers. |
| Design to Figma clipboard | Copies an SVG built from the live rendered DOM. Figma imports supported SVG primitives as editable layers. | Visual/vector handoff, not a native semantic round trip. Auto layout, variables, components, prototypes, HTML state, and code identity are not recreated by SVG. | Paste into real Figma and inspect layer types, text, images, effects, clipping, and bounds. |
| Design SVG download | Same conversion as clipboard, with a server-render fallback when a live DOM is unavailable. | Same SVG limits; the export report lists approximations and omissions. | Live and server paths, selected layer and whole screen. |
Expand Down
324 changes: 324 additions & 0 deletions templates/design/actions/hydrate-figma-paste-images.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
/**
* hydrate-figma-paste-images.spec.ts
*
* Covers:
* - collectImageRefHashes: scan HTML for data-figma-image-ref hashes
* - hydrateImageRefsInHtml: replace url("about:blank") with real URLs in order
* - Action routing: no-refs early return, full resolution, partial resolution,
* no-figmaFileKey guard, Figma-returns-empty guard
*/

import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
db: {
select: vi.fn(),
from: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
limit: vi.fn(),
},
assertAccess: vi.fn(),
accessFilter: vi.fn(() => "access-filter-sentinel"),
readLiveSourceFile: vi.fn(),
writeInlineSourceFile: vi.fn(),
agentEnterDocument: vi.fn(),
agentLeaveDocument: vi.fn(),
resolveImageFillRefs: vi.fn(),
mutateDesignData: vi.fn(),
}));

vi.mock("@agent-native/core/sharing", () => ({
assertAccess: mocks.assertAccess,
accessFilter: mocks.accessFilter,
}));

vi.mock("@agent-native/core/collab", () => ({
agentEnterDocument: mocks.agentEnterDocument,
agentLeaveDocument: mocks.agentLeaveDocument,
}));

vi.mock("../server/source-workspace.js", () => ({
readLiveSourceFile: mocks.readLiveSourceFile,
writeInlineSourceFile: mocks.writeInlineSourceFile,
}));

vi.mock("../server/lib/figma-node-import.js", () => ({
resolveImageFillRefs: mocks.resolveImageFillRefs,
}));

vi.mock("../server/lib/design-data-mutation.js", () => ({
mutateDesignData: mocks.mutateDesignData,
}));

// db query chain builder that always resolves to whatever rows array is set
let dbRows: unknown[] = [];
vi.mock("../server/db/index.js", () => ({
getDb: () => ({
select: () => ({
from: () => ({
innerJoin: () => ({
where: () => ({
limit: () => Promise.resolve(dbRows),
}),
}),
}),
}),
}),
schema: {
designFiles: { id: "id", designId: "designId", filename: "filename", fileType: "fileType", content: "content" },
designs: { id: "id", data: "data" },
designShares: {},
},
}));

import {
collectImageRefHashes,
hydrateImageRefsInHtml,
} from "./hydrate-figma-paste-images.js";
import action from "./hydrate-figma-paste-images.js";

// ---------------------------------------------------------------------------
// Pure HTML helpers
// ---------------------------------------------------------------------------

describe("collectImageRefHashes", () => {
it("returns empty array for HTML with no data-figma-image-ref attrs", () => {
const html = '<div style="color: red;"><span>hello</span></div>';
expect(collectImageRefHashes(html)).toEqual([]);
});

it("collects hashes from a single element", () => {
const html =
'<div data-figma-image-ref="abc123" style="background-image: url(&quot;about:blank&quot;);">x</div>';
expect(collectImageRefHashes(html)).toEqual(["abc123"]);
});

it("collects multiple hashes from a single element", () => {
const html =
'<div data-figma-image-ref="hash1 hash2" style="background-image: url(&quot;about:blank&quot;), url(&quot;about:blank&quot;);">x</div>';
expect(collectImageRefHashes(html)).toEqual(["hash1", "hash2"]);
});

it("deduplicates the same hash across multiple elements", () => {
const html = [
'<div data-figma-image-ref="shared">',
'<span data-figma-image-ref="shared unique">',
].join("");
const hashes = collectImageRefHashes(html);
expect(hashes).toEqual(["shared", "unique"]);
});

it("ignores closing tags and unrelated attributes", () => {
const html =
'</div><div data-other="abc123"><span data-figma-image-ref="realHash">x</span>';
expect(collectImageRefHashes(html)).toEqual(["realHash"]);
});
});

describe("hydrateImageRefsInHtml", () => {
it("leaves HTML unchanged when no data-figma-image-ref attrs are present", () => {
const html = '<div style="color: red;">text</div>';
const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, new Map());
expect(out).toBe(html);
expect(resolved).toBe(0);
expect(missing).toEqual([]);
});

it("replaces url placeholder with durable URL and removes the attr when fully resolved", () => {
const html =
'<div data-figma-image-ref="abc123" style="background-image: url(&quot;about:blank&quot;); width: 100px;">x</div>';
const urls = new Map([["abc123", "https://cdn.example.com/img.png"]]);

const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, urls);

expect(resolved).toBe(1);
expect(missing).toEqual([]);
expect(out).not.toContain("data-figma-image-ref");
expect(out).toContain('url(&quot;https://cdn.example.com/img.png&quot;)');
expect(out).not.toContain("about:blank");
});

it("replaces multiple url placeholders in order matching the hashes", () => {
const html =
'<div data-figma-image-ref="h1 h2" style="background-image: url(&quot;about:blank&quot;), url(&quot;about:blank&quot;);">x</div>';
const urls = new Map([
["h1", "https://cdn.example.com/img1.png"],
["h2", "https://cdn.example.com/img2.png"],
]);

const { html: out, resolved } = hydrateImageRefsInHtml(html, urls);

expect(resolved).toBe(2);
expect(out).toContain('url(&quot;https://cdn.example.com/img1.png&quot;)');
expect(out).toContain('url(&quot;https://cdn.example.com/img2.png&quot;)');
expect(out).not.toContain("data-figma-image-ref");
});

it("keeps placeholder and preserves attr for unresolved hashes", () => {
const html =
'<div data-figma-image-ref="abc123" style="background-image: url(&quot;about:blank&quot;);">x</div>';

const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, new Map());

expect(resolved).toBe(0);
expect(missing).toEqual(["abc123"]);
expect(out).toContain("data-figma-image-ref");
expect(out).toContain("about:blank");
});

it("partially resolves: updates attr with remaining hashes for unresolved", () => {
const html =
'<div data-figma-image-ref="h1 h2" style="background-image: url(&quot;about:blank&quot;), url(&quot;about:blank&quot;);">x</div>';
const urls = new Map([["h1", "https://cdn.example.com/img1.png"]]);

const { html: out, resolved, missing } = hydrateImageRefsInHtml(html, urls);

expect(resolved).toBe(1);
expect(missing).toEqual(["h2"]);
expect(out).toContain('data-figma-image-ref="h2"');
expect(out).toContain('url(&quot;https://cdn.example.com/img1.png&quot;)');
expect(out).toContain('url(&quot;about:blank&quot;)');
});

it("encodes & in durable URLs as &amp;", () => {
const html =
'<div data-figma-image-ref="abc" style="background-image: url(&quot;about:blank&quot;);">x</div>';
const urls = new Map([["abc", "https://cdn.example.com/img?a=1&b=2"]]);

const { html: out } = hydrateImageRefsInHtml(html, urls);
expect(out).toContain(
'url(&quot;https://cdn.example.com/img?a=1&amp;b=2&quot;)',
);
});
});

// ---------------------------------------------------------------------------
// Action integration (with mocks)
// ---------------------------------------------------------------------------

const FILE_KEY = "testFileKey123";

const SCREEN_METADATA_ROW = {
id: "file-1",
designId: "design-1",
filename: "Screen.html",
fileType: "html",
content: '<div data-figma-image-ref="abc123" style="background-image: url(&quot;about:blank&quot;);">x</div>',
designData: JSON.stringify({
screenMetadata: {
"file-1": {
figmaFileKey: FILE_KEY,
unresolvedImageRefs: ["abc123"],
},
},
}),
};

describe("hydrate-figma-paste-images action", () => {
beforeEach(() => {
vi.clearAllMocks();
dbRows = [];
mocks.assertAccess.mockResolvedValue(undefined);
mocks.writeInlineSourceFile.mockResolvedValue({
versionHash: "new-hash",
changed: true,
updatedAt: "2025-01-01T00:00:00Z",
});
mocks.mutateDesignData.mockImplementation(async ({ mutate, isApplied }: any) => {
const data = mutate({
screenMetadata: { "file-1": { figmaFileKey: FILE_KEY, unresolvedImageRefs: ["abc123"] } },
}, { updatedAt: "" });
expect(isApplied(data)).toBe(true);
return { data, updatedAt: "" };
});
});

it("returns early when no image ref attrs found in live content", async () => {
dbRows = [{ ...SCREEN_METADATA_ROW, content: "<div>no refs here</div>" }];
mocks.readLiveSourceFile.mockResolvedValue({
content: "<div>no refs here</div>",
versionHash: "v1",
});

const result = await action.run({ fileId: "file-1" });

expect(result).toMatchObject({ resolved: 0, missing: 0, skipped: 0 });
expect(mocks.resolveImageFillRefs).not.toHaveBeenCalled();
expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled();
});

it("resolves all image refs and writes the updated HTML", async () => {
dbRows = [SCREEN_METADATA_ROW];
mocks.readLiveSourceFile.mockResolvedValue({
content: SCREEN_METADATA_ROW.content,
versionHash: "v1",
});
mocks.resolveImageFillRefs.mockResolvedValue(
new Map([["abc123", "https://cdn.example.com/img.png"]]),
);

const result = await action.run({ fileId: "file-1" });

expect(result).toMatchObject({ resolved: 1, missing: 0 });
expect(mocks.resolveImageFillRefs).toHaveBeenCalledWith(FILE_KEY, ["abc123"]);
const writtenContent = mocks.writeInlineSourceFile.mock.calls[0]![0].content as string;
expect(writtenContent).toContain("https://cdn.example.com/img.png");
expect(writtenContent).not.toContain("about:blank");
expect(writtenContent).not.toContain("data-figma-image-ref");
expect(mocks.mutateDesignData).toHaveBeenCalledTimes(1);
});

it("reports missing count and skips write when Figma returns no URLs", async () => {
dbRows = [SCREEN_METADATA_ROW];
mocks.readLiveSourceFile.mockResolvedValue({
content: SCREEN_METADATA_ROW.content,
versionHash: "v1",
});
mocks.resolveImageFillRefs.mockResolvedValue(new Map());

const result = await action.run({ fileId: "file-1" });

expect(result).toMatchObject({ resolved: 0, missing: 1 });
expect(mocks.writeInlineSourceFile).not.toHaveBeenCalled();
expect(mocks.mutateDesignData).not.toHaveBeenCalled();
});

it("throws when no figmaFileKey is in screenMetadata for the file", async () => {
dbRows = [
{
...SCREEN_METADATA_ROW,
designData: JSON.stringify({ screenMetadata: { "file-1": { title: "Screen" } } }),
},
];

await expect(action.run({ fileId: "file-1" })).rejects.toThrow(
"No Figma file key found",
);
expect(mocks.resolveImageFillRefs).not.toHaveBeenCalled();
});

it("throws when file is not found", async () => {
dbRows = [];

await expect(action.run({ fileId: "missing-file" })).rejects.toThrow(
"File not found",
);
});

it("calls agentEnterDocument/agentLeaveDocument around the write", async () => {
dbRows = [SCREEN_METADATA_ROW];
mocks.readLiveSourceFile.mockResolvedValue({
content: SCREEN_METADATA_ROW.content,
versionHash: "v1",
});
mocks.resolveImageFillRefs.mockResolvedValue(
new Map([["abc123", "https://cdn.example.com/img.png"]]),
);

await action.run({ fileId: "file-1" });

expect(mocks.agentEnterDocument).toHaveBeenCalledWith("file-1");
expect(mocks.agentLeaveDocument).toHaveBeenCalledWith("file-1");
});
});
Loading
Loading