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 `${escapeHtml(node.name)}`; }