From 57d55ce07da8157a429b8ead3d2ebe2d09e717e7 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Tue, 24 Mar 2026 23:30:21 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20design-tree=20property=20loss=20?= =?UTF-8?q?=E2=80=94=204=20fixes=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. fill.visible=false: skip invisible fills instead of rendering them 2. individualStrokeWeights: output per-side borders (border-bottom etc) instead of always border: 1px solid 3. textDecoration: output text-decoration for UNDERLINE/STRIKETHROUGH 4. IMAGE fill type: output background-image: [IMAGE] marker Also: - Add strokeWeight/individualStrokeWeights to AnalysisNode schema - Add these fields to figma-transformer extraction - Fill opacity now outputs rgba() when < 1 - 7 new tests Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/adapters/figma-transformer.ts | 6 ++ src/core/contracts/figma-node.ts | 2 + src/core/engine/design-tree.test.ts | 126 ++++++++++++++++++++++++- src/core/engine/design-tree.ts | 61 ++++++++++-- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/core/adapters/figma-transformer.ts b/src/core/adapters/figma-transformer.ts index 52b724e3..0ae7fd19 100644 --- a/src/core/adapters/figma-transformer.ts +++ b/src/core/adapters/figma-transformer.ts @@ -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; + } if ("effects" in node) { base.effects = node.effects as unknown[]; } diff --git a/src/core/contracts/figma-node.ts b/src/core/contracts/figma-node.ts index 37823e38..9db07f2f 100644 --- a/src/core/contracts/figma-node.ts +++ b/src/core/contracts/figma-node.ts @@ -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(), diff --git a/src/core/engine/design-tree.test.ts b/src/core/engine/design-tree.test.ts index 43011edd..8841b3ff 100644 --- a/src/core/engine/design-tree.test.ts +++ b/src/core/engine/design-tree.test.ts @@ -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", () => { @@ -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", @@ -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)", () => { @@ -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", () => { diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index 69040f68..e4044b6f 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -16,13 +16,38 @@ 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; +} + +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; } function getStroke(node: AnalysisNode): string | null { @@ -102,12 +127,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`); @@ -130,6 +169,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}`); From 4f351cee435b510626b0948b391f167cbd23dd74 Mon Sep 17 00:00:00 2001 From: let-sunny Date: Tue, 24 Mar 2026 23:37:16 +0900 Subject: [PATCH 2/2] fix: add docstrings to design-tree.ts functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses docstring coverage check (16.67% → 80%+ threshold). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/core/engine/design-tree.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index e4044b6f..2bea9f09 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -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); @@ -21,6 +22,7 @@ interface FillInfo { 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; @@ -50,6 +52,7 @@ 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) { @@ -59,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) { @@ -79,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 = { MIN: "flex-start", @@ -89,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 "";