fix(core,cli): figma IMAGE fills dropped, rasterize double-paint, tokens false-success

nodeToHtml routed rasterize eligibility off node.type alone, so a
RECTANGLE/FRAME with an IMAGE fill fell through to the generic <div>
path — fillCss() has no IMAGE case, so it rendered an empty box.
IMAGE-filled nodes now route to rasterize like vectors, regardless of
node.type.

Rasterized nodes (vectors, now image fills too) were also getting
their own fill/corner-radius CSS applied on top of the already-
rendered <img> — a flat color block behind/around the real art,
flattening non-rectangular shapes into rounded rects. decorationCss
now skips background and corner-radius/clip for rasterized nodes;
opacity and effects still apply since those aren't baked into the
export.

tokens.ts's styles-fallback path hardcoded entries: [] regardless of
how many published styles were actually found, so the CLI printed
"recorded published style metadata instead" even when styles()
returned zero results. Added styleCount to the result so the message
reflects what happened, and points at the MCP get_variable_defs
fallback when there's nothing to fall back to.

Co-Authored-By: Claude Opus <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 19:23:19 -07:00
co-authored by Claude Opus
parent 43af582534
commit c8eff1a4ba
4 changed files with 105 additions and 21 deletions
@@ -57,12 +57,24 @@ describe("runTokensImport", () => {
const out = await runTokensImport("FILE", { projectDir: dir, client: gated });
expect(out.mode).toBe("styles");
expect(out.entries).toEqual([]);
expect(out.styleCount).toBe(1);
const sidecar = JSON.parse(readFileSync(join(dir, "figma-tokens.json"), "utf8")) as {
tokens: Array<{ name: string; type: string }>;
};
expect(sidecar.tokens[0]).toMatchObject({ name: "Primary", type: "style:FILL" });
});
it("reports styleCount 0 when the file has no published styles — never a false success", async () => {
const gatedNoStyles = client({
variables: () =>
Promise.reject(new FigmaClientError("REQUIRES_ENTERPRISE", "enterprise only", 403)),
styles: () => Promise.resolve([]),
});
const out = await runTokensImport("FILE", { projectDir: dir, client: gatedNoStyles });
expect(out.mode).toBe("styles");
expect(out.styleCount).toBe(0);
});
it("propagates non-enterprise failures", async () => {
const broken = client({
variables: () => Promise.reject(new FigmaClientError("RATE_LIMITED", "429", 429)),
+8 -2
View File
@@ -30,6 +30,10 @@ export interface TokensImportResult {
mode: "variables" | "styles";
entries: CompositionVariableEntry[];
sidecarPath: string;
/** styles mode only: how many published styles were actually found —
* entries is always [] in this mode (style values resolve later, at
* component-import time), so this is what tells success from empty. */
styleCount?: number;
}
export async function runTokensImport(
@@ -68,7 +72,7 @@ export async function runTokensImport(
})),
};
writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n");
return { mode: "styles", entries: [], sidecarPath };
return { mode: "styles", entries: [], sidecarPath, styleCount: styles.length };
}
export default defineCommand({
@@ -84,7 +88,9 @@ export default defineCommand({
const result = await runTokensImport(args.ref, { projectDir: args.dir, client });
if (result.mode === "styles") {
console.log(
"variables are Enterprise-gated on this plan — recorded published style metadata instead (style values resolve at component-import time)",
(result.styleCount ?? 0) > 0
? `variables are Enterprise-gated on this plan — recorded ${result.styleCount} published style(s) instead (style values resolve at component-import time)`
: "variables are Enterprise-gated on this plan, and this file has no published library styles to fall back to — nothing recorded. Publish the file's styles to a team library, or read variables via the Figma MCP connector's get_variable_defs instead (works on any plan, rate-limited).",
);
}
console.log(`wrote ${result.sidecarPath} (${result.mode})`);
@@ -234,6 +234,47 @@ describe("nodeToHtml", () => {
expect(out.html).toContain("<img");
});
it("routes IMAGE fills to the rasterize list regardless of node.type", () => {
const out = nodeToHtml(
frame([
{
id: "1:8",
name: "Sneaker Photo",
type: "RECTANGLE",
absoluteBoundingBox: BOX(120, 220, 200, 200),
fills: [{ type: "IMAGE", imageRef: "abc123" }],
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.rasterize).toEqual([
{ nodeId: "1:8", name: "Sneaker Photo", slug: "sneaker-photo" },
]);
expect(out.html).toContain('data-figma-rasterize="1:8"');
expect(out.html).toContain("<img");
});
it("does not double-paint a rasterized node's own fill/corner-radius onto its img", () => {
const out = nodeToHtml(
frame([
{
id: "1:9",
name: "Blob",
type: "VECTOR",
absoluteBoundingBox: BOX(120, 220, 64, 64),
fills: [SOLID_BLUE],
cornerRadius: 12,
opacity: 0.5,
},
]),
{ resolved: [], unresolved: [] },
);
expect(out.html).not.toContain("background-color: #0066FF");
expect(out.html).not.toContain("border-radius: 12px");
// opacity is compositing, not shape — still applies on top of the export
expect(out.html).toContain("opacity: 0.5");
});
it("skips invisible nodes and invisible fills (respects visible:false)", () => {
const out = nodeToHtml(
frame([
+44 -19
View File
@@ -7,8 +7,11 @@
* - CSS where CSS is faithful: solid/linear-gradient fills, corner radius,
* opacity, drop shadow, blur, text styles.
* - Everything CSS can't match faithfully (vectors, boolean ops, exotic
* paint) routes to the rasterize list — the caller exports those nodes as
* images (Phase 1) and fills in the placeholder src.
* paint, IMAGE fills) routes to the rasterize list — the caller exports
* those nodes as images (Phase 1) and fills in the placeholder src. A
* rasterized node's own fill/corner-radius CSS is never emitted — the
* exported image already contains it; adding both double-paints (a flat
* color block behind/around the real art).
* - Bindings (§7.1): resolved sites emit var(--slug, literal) so a brand
* refresh propagates; unresolved sites bake the literal and carry a
* data-figma-unresolved flag. Never a dangling var().
@@ -113,6 +116,13 @@ function fillCss(node: FigmaNodeDocument): string | null {
return null;
}
/** IMAGE fills (photos, icons pasted as bitmaps) have no CSS equivalent —
* route to rasterize like vectors, regardless of node.type (a plain
* RECTANGLE/FRAME carries the fill just as often as a dedicated image node). */
function hasImageFill(node: FigmaNodeDocument): boolean {
return firstVisibleFill(node)?.type === "IMAGE";
}
function dropShadowCss(effect: Record<string, unknown>): string | null {
if (!isRecord(effect.offset)) return null;
const color = figmaColorToCss(effect.color);
@@ -234,33 +244,47 @@ function geometryCss(node: FigmaNodeDocument, parentBox: Box, isRoot: boolean):
return styles;
}
function shapeCss(node: FigmaNodeDocument, styles: string[]): void {
/** Corner-radius + clip describe the node's OWN shape — meaningless once
* that shape has already been baked into a rasterized image (see
* decorationCss). Opacity stays separate: it's compositing, still correct
* to apply on top of a raster/vector export. */
function cornerAndClipCss(node: FigmaNodeDocument, styles: string[]): void {
if (node.type === "ELLIPSE") {
styles.push("border-radius: 50%");
} else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
styles.push(`border-radius: ${round(node.cornerRadius)}px`);
}
if (node.clipsContent === true) styles.push("overflow: hidden");
}
function opacityCss(node: FigmaNodeDocument, styles: string[]): void {
if (typeof node.opacity === "number" && node.opacity < 1)
styles.push(`opacity: ${round(node.opacity)}`);
}
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext): string[] {
function decorationCss(node: FigmaNodeDocument, ctx: RenderContext, rasterized: boolean): string[] {
const styles: string[] = [];
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
// color goes through it too, so a token-bound text fill keeps its link.
const bg = backgroundValue(node, ctx);
if (node.type === "TEXT") {
if (bg !== null) styles.push(`color: ${bg}`);
textCss(node, styles);
} else if (bg !== null) {
// background-color (longhand) for solid fills, never the shorthand: GSAP
// backgroundColor tweens can't read a var() through the shorthand (its
// pending-substitution longhands serialize empty), so .from/.to on an
// imported node would settle on transparent instead of the token color.
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
// A rasterized node's fill/shape is already baked into the exported image
// — background-color/border-radius on top of it would double-paint (a
// flat color block behind or around the real art). Opacity and effects
// (shadow/blur) aren't baked by the export, so those still apply.
if (!rasterized) {
// backgroundValue is the binding-aware path (var(--slug, literal)) — TEXT
// color goes through it too, so a token-bound text fill keeps its link.
const bg = backgroundValue(node, ctx);
if (node.type === "TEXT") {
if (bg !== null) styles.push(`color: ${bg}`);
textCss(node, styles);
} else if (bg !== null) {
// background-color (longhand) for solid fills, never the shorthand: GSAP
// backgroundColor tweens can't read a var() through the shorthand (its
// pending-substitution longhands serialize empty), so .from/.to on an
// imported node would settle on transparent instead of the token color.
styles.push(bg.includes("gradient(") ? `background: ${bg}` : `background-color: ${bg}`);
}
cornerAndClipCss(node, styles);
}
shapeCss(node, styles);
opacityCss(node, styles);
effectsCss(node, styles);
return styles;
}
@@ -292,15 +316,16 @@ function renderNodeHtml(
): string {
if (node.visible === false || depth > MAX_DEPTH) return "";
const slug = uniqueSlug(ctx, node.name);
const rasterized = RASTERIZE_TYPES.has(node.type) || hasImageFill(node);
const style = escapeHtml(
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx)].join("; "),
[...geometryCss(node, parentBox, isRoot), ...decorationCss(node, ctx, rasterized)].join("; "),
);
// data-hf-snippet marks the file as a mountable fragment, not a standalone
// composition — the project linter skips composition-root rules for it.
const snippetAttr = isRoot ? ' data-hf-snippet=""' : "";
const idAttrs = `id="${slug}"${snippetAttr} data-figma-id="${escapeHtml(node.id)}"${unresolvedAttr(node, ctx)}`;
if (RASTERIZE_TYPES.has(node.type)) {
if (rasterized) {
ctx.rasterize.push({ nodeId: node.id, name: node.name, slug });
return `<img ${idAttrs} data-figma-rasterize="${escapeHtml(node.id)}" alt="${escapeHtml(node.name)}" style="${style}" />`;
}