Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions src/core/adapters/figma-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ function transformNode(node: Node): AnalysisNode {
if ("strokes" in node) {
base.strokes = node.strokes as unknown[];
}
if ("strokeWeight" in node && typeof node.strokeWeight === "number") {
base.strokeWeight = node.strokeWeight;
}
if ("individualStrokeWeights" in node && node.individualStrokeWeights) {
base.individualStrokeWeights = node.individualStrokeWeights as Record<string, number>;
}
if ("effects" in node) {
base.effects = node.effects as unknown[];
}
Expand Down
2 changes: 2 additions & 0 deletions src/core/contracts/figma-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ const BaseAnalysisNodeSchema = z.object({
styles: z.record(z.string(), z.string()).optional(),
fills: z.array(z.unknown()).optional(),
strokes: z.array(z.unknown()).optional(),
strokeWeight: z.number().optional(),
individualStrokeWeights: z.record(z.string(), z.number()).optional(),
effects: z.array(z.unknown()).optional(),
cornerRadius: z.number().optional(),

Expand Down
126 changes: 125 additions & 1 deletion src/core/engine/design-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,40 @@ describe("generateDesignTree", () => {
expect(output).toContain("line-height: 24px");
expect(output).toContain("letter-spacing: 0.5px");
});

it("TEXT nodes include textDecoration when set", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "Link",
type: "TEXT",
characters: "Read our T&Cs",
style: { fontFamily: "Inter", textDecoration: "UNDERLINE" },
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 20 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("text-decoration: underline");
});

it("TEXT nodes skip textDecoration: NONE", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "Normal",
type: "TEXT",
characters: "Normal text",
style: { fontFamily: "Inter", textDecoration: "NONE" },
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 20 },
})
);

const output = generateDesignTree(file);

expect(output).not.toContain("text-decoration");
});
});

describe("layout nodes", () => {
Expand Down Expand Up @@ -310,7 +344,24 @@ describe("generateDesignTree", () => {
});

describe("strokes", () => {
it("stroke outputs as border with 1px solid and hex color", () => {
it("stroke outputs as border with strokeWeight and hex color", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "BorderedBox",
type: "RECTANGLE",
strokes: [{ type: "SOLID", color: { r: 1, g: 0, b: 0 } }],
strokeWeight: 2,
absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("border: 2px solid #FF0000");
});

it("defaults to 1px when strokeWeight is not set", () => {
const file = makeFile(
makeNode({
id: "1:1",
Expand All @@ -325,6 +376,27 @@ describe("generateDesignTree", () => {

expect(output).toContain("border: 1px solid #FF0000");
});

it("individualStrokeWeights outputs per-side borders", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "BottomBorder",
type: "FRAME",
strokes: [{ type: "SOLID", color: { r: 0.85, g: 0.85, b: 0.85 } }],
individualStrokeWeights: { top: 0, right: 0, bottom: 1, left: 0 },
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 50 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("border-bottom: 1px solid");
expect(output).not.toContain("border-top:");
expect(output).not.toContain("border-right:");
expect(output).not.toContain("border-left:");
expect(output).not.toContain("border: ");
});
});

describe("fills (non-TEXT)", () => {
Expand All @@ -344,6 +416,58 @@ describe("generateDesignTree", () => {
expect(output).toContain("background: #");
expect(output).not.toContain("color: #");
});

it("skips fills with visible: false", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "InvisibleFill",
type: "FRAME",
fills: [{ type: "SOLID", visible: false, color: { r: 1, g: 1, b: 1 } }],
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
})
);

const output = generateDesignTree(file);

expect(output).not.toContain("background:");
});

it("IMAGE fill type outputs background-image: [IMAGE]", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "ImageFrame",
type: "FRAME",
fills: [{ type: "IMAGE", scaleMode: "FILL", imageRef: "abc123" }],
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("background-image: [IMAGE]");
});

it("shows both background and background-image when both fill types exist", () => {
const file = makeFile(
makeNode({
id: "1:1",
name: "MixedFill",
type: "FRAME",
fills: [
{ type: "SOLID", color: { r: 0.9, g: 0.9, b: 0.95 } },
{ type: "IMAGE", scaleMode: "FILL", imageRef: "xyz" },
],
absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 200 },
})
);

const output = generateDesignTree(file);

expect(output).toContain("background: #");
expect(output).toContain("background-image: [IMAGE]");
});
});

describe("invisible nodes", () => {
Expand Down
67 changes: 58 additions & 9 deletions src/core/engine/design-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { AnalysisFile, AnalysisNode } from "../contracts/figma-node.js";

/** Convert Figma RGBA color object to CSS hex string. */
function rgbaToHex(color: { r?: number; g?: number; b?: number; a?: number }): string | null {
if (!color) return null;
const r = Math.round((color.r ?? 0) * 255);
Expand All @@ -16,15 +17,42 @@ function rgbaToHex(color: { r?: number; g?: number; b?: number; a?: number }): s
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`.toUpperCase();
}

function getFill(node: AnalysisNode): string | null {
if (!node.fills || !Array.isArray(node.fills)) return null;
interface FillInfo {
color: string | null;
hasImage: boolean;
}

/** Extract fill color and IMAGE presence from a node, skipping invisible fills. */
function getFillInfo(node: AnalysisNode): FillInfo {
const result: FillInfo = { color: null, hasImage: false };
if (!node.fills || !Array.isArray(node.fills)) return result;
for (const fill of node.fills) {
const f = fill as { type?: string; color?: { r?: number; g?: number; b?: number } };
if (f.type === "SOLID" && f.color) return rgbaToHex(f.color);
const f = fill as { type?: string; visible?: boolean; color?: { r?: number; g?: number; b?: number; a?: number }; opacity?: number };
// Skip invisible fills
if (f.visible === false) continue;
if (f.type === "SOLID" && f.color) {
const opacity = f.opacity ?? f.color.a ?? 1;
if (opacity < 1) {
const r = Math.round((f.color.r ?? 0) * 255);
const g = Math.round((f.color.g ?? 0) * 255);
const b = Math.round((f.color.b ?? 0) * 255);
result.color = `rgba(${r}, ${g}, ${b}, ${opacity})`;
} else {
result.color = rgbaToHex(f.color);
}
} else if (f.type === "IMAGE") {
result.hasImage = true;
}
}
return null;
return result;
}

/** @deprecated Use getFillInfo instead for full fill details */
function getFill(node: AnalysisNode): string | null {
return getFillInfo(node).color;
}

/** Extract the first solid stroke color as a CSS hex string. */
function getStroke(node: AnalysisNode): string | null {
if (!node.strokes || !Array.isArray(node.strokes)) return null;
for (const stroke of node.strokes) {
Expand All @@ -34,6 +62,7 @@ function getStroke(node: AnalysisNode): string | null {
return null;
}

/** Extract the first visible shadow effect as a CSS box-shadow value. */
function getShadow(node: AnalysisNode): string | null {
if (!node.effects || !Array.isArray(node.effects)) return null;
for (const effect of node.effects) {
Expand All @@ -54,6 +83,7 @@ function getShadow(node: AnalysisNode): string | null {
return null;
}

/** Map Figma alignment values to CSS flexbox equivalents. */
function mapAlign(figmaAlign: string): string {
const map: Record<string, string> = {
MIN: "flex-start",
Expand All @@ -64,6 +94,7 @@ function mapAlign(figmaAlign: string): string {
return map[figmaAlign] ?? figmaAlign;
}

/** Render a single node and its children as indented design-tree text. */
function renderNode(node: AnalysisNode, indent: number, vectorDir?: string): string {
if (node.visible === false) return "";

Expand Down Expand Up @@ -102,12 +133,26 @@ function renderNode(node: AnalysisNode, indent: number, vectorDir?: string): str
if (node.layoutSizingVertical === "FILL") styles.push("height: 100%");

// Fill (not for TEXT — text fill is color)
const fill = getFill(node);
if (fill && node.type !== "TEXT") styles.push(`background: ${fill}`);
const fillInfo = getFillInfo(node);
if (fillInfo.color && node.type !== "TEXT") styles.push(`background: ${fillInfo.color}`);
if (fillInfo.hasImage) styles.push("background-image: [IMAGE]");

// Border
// Border — respect per-side stroke weights
const stroke = getStroke(node);
if (stroke) styles.push(`border: 1px solid ${stroke}`);
if (stroke) {
const isw = node.individualStrokeWeights as
| { top?: number; right?: number; bottom?: number; left?: number }
| undefined;
const sw = (node.strokeWeight as number | undefined) ?? 1;
if (isw) {
if (isw.top) styles.push(`border-top: ${isw.top}px solid ${stroke}`);
if (isw.right) styles.push(`border-right: ${isw.right}px solid ${stroke}`);
if (isw.bottom) styles.push(`border-bottom: ${isw.bottom}px solid ${stroke}`);
if (isw.left) styles.push(`border-left: ${isw.left}px solid ${stroke}`);
} else {
styles.push(`border: ${sw}px solid ${stroke}`);
}
}

// Border radius
if (node.cornerRadius) styles.push(`border-radius: ${node.cornerRadius}px`);
Expand All @@ -130,6 +175,10 @@ function renderNode(node: AnalysisNode, indent: number, vectorDir?: string): str
const ls = s["letterSpacing"] as number;
styles.push(`letter-spacing: ${Math.round(ls * 100) / 100}px`);
}
if (s["textDecoration"]) {
const td = (s["textDecoration"] as string).toLowerCase();
if (td !== "none") styles.push(`text-decoration: ${td}`);
}

const textColor = getFill(node);
if (textColor) styles.push(`color: ${textColor}`);
Expand Down
Loading