diff --git a/packages/cli/src/commands/figma/tokens.test.ts b/packages/cli/src/commands/figma/tokens.test.ts
index f43943432..8f7f1c6e9 100644
--- a/packages/cli/src/commands/figma/tokens.test.ts
+++ b/packages/cli/src/commands/figma/tokens.test.ts
@@ -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)),
diff --git a/packages/cli/src/commands/figma/tokens.ts b/packages/cli/src/commands/figma/tokens.ts
index a1cc6a69e..87ac8a8d9 100644
--- a/packages/cli/src/commands/figma/tokens.ts
+++ b/packages/cli/src/commands/figma/tokens.ts
@@ -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})`);
diff --git a/packages/core/src/figma/nodeToHtml.test.ts b/packages/core/src/figma/nodeToHtml.test.ts
index 65d332e9f..ef1898944 100644
--- a/packages/core/src/figma/nodeToHtml.test.ts
+++ b/packages/core/src/figma/nodeToHtml.test.ts
@@ -234,6 +234,47 @@ describe("nodeToHtml", () => {
expect(out.html).toContain("
{
+ 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("
{
+ 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([
diff --git a/packages/core/src/figma/nodeToHtml.ts b/packages/core/src/figma/nodeToHtml.ts
index e1779d77c..1610e8800 100644
--- a/packages/core/src/figma/nodeToHtml.ts
+++ b/packages/core/src/figma/nodeToHtml.ts
@@ -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 | 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 `
`;
}