Skip to content

Commit a077ace

Browse files
zerone0xClawdbotclaudeGLips
authored
fix: disambiguate named styles with duplicate names (#319)
* fix: disambiguate named styles with duplicate names Fixes #265 Co-Authored-By: Claude <noreply@anthropic.com> * fix: use raw style ID for collision disambiguation Use the full Figma style ID (which is a node ID like "161:300") as the suffix instead of stripping non-alphanumeric characters. Produces readable output like "Heading / Large (161:300)" and avoids collisions that could occur from truncating the ID. Also updates test to use realistic Figma-format style IDs and names. * refactor: extract registerStyle helper for style registration Consolidates the repeated named-style-or-anonymous-var pattern into a single registerStyle function. Used for text styles, fills, and effects. Strokes remain explicit because named stroke styles store only colors (with weight/dashes kept on the node) while anonymous strokes bundle everything into one variable. * refactor: unify stroke color handling with fills and effects Stroke colors now go through registerStyle like fills and effects. Stroke metadata (weight, dashes) always lives on the node rather than sometimes being bundled into the style var. * refactor: use fill prefix for stroke colors to enable deduplication Stroke colors are structurally identical to fill colors in Figma (both are FILL-type styles). Using the same prefix allows identical colors used as both fills and strokes to deduplicate into a single global var. * test: verify fill/stroke color deduplication uses fill prefix Stroke node is placed first to ensure the test catches regressions where stroke colors get a different prefix than fills, which would prevent cross-context deduplication of identical colors. * fix: align fill and stroke paint processing Filter invisible paints from fills (matching strokes behavior) and reverse stroke colors to CSS stacking order (matching fills behavior). Both pipelines now process paints identically: filter visible, parse, reverse. --------- Co-authored-by: Clawdbot <bot@clawd.bot> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Graham Lipsman <graham@branchlabs.com>
1 parent 354679e commit a077ace

3 files changed

Lines changed: 127 additions & 41 deletions

File tree

src/extractors/built-in.ts

Lines changed: 49 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
isTextNode,
1616
} from "~/transformers/text.js";
1717
import { hasValue, isRectangleCornerRadii } from "~/utils/identity.js";
18-
import { generateVarId } from "~/utils/common.js";
18+
import { generateVarId, isVisible } from "~/utils/common.js";
1919
import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec";
2020

2121
// Reverse lookup cache: serialized style value → varId.
@@ -48,6 +48,26 @@ function findOrCreateVar(globalVars: GlobalVars, value: StyleTypes, prefix: stri
4848
return varId;
4949
}
5050

51+
/**
52+
* Register a style value, preferring a Figma named style when available.
53+
* Falls back to an auto-generated deduplicating variable ID.
54+
*/
55+
function registerStyle(
56+
node: FigmaDocumentNode,
57+
context: TraversalContext,
58+
value: StyleTypes,
59+
styleKeys: string[],
60+
prefix: string,
61+
): string {
62+
const styleMatch = getStyleMatch(node, context, styleKeys);
63+
if (styleMatch) {
64+
const styleKey = resolveStyleKey(context, styleMatch, value);
65+
context.globalVars.styles[styleKey] = value;
66+
return styleKey;
67+
}
68+
return findOrCreateVar(context.globalVars, value, prefix);
69+
}
70+
5171
/**
5272
* Extracts layout-related properties from a node.
5373
*/
@@ -71,14 +91,7 @@ export const textExtractor: ExtractorFn = (node, result, context) => {
7191
if (hasTextStyle(node)) {
7292
const textStyle = extractTextStyle(node);
7393
if (textStyle) {
74-
// Prefer Figma named style when available
75-
const styleName = getStyleName(node, context, ["text", "typography"]);
76-
if (styleName) {
77-
context.globalVars.styles[styleName] = textStyle;
78-
result.textStyle = styleName;
79-
} else {
80-
result.textStyle = findOrCreateVar(context.globalVars, textStyle, "style");
81-
}
94+
result.textStyle = registerStyle(node, context, textStyle, ["text", "typography"], "style");
8295
}
8396
}
8497
};
@@ -93,43 +106,26 @@ export const visualsExtractor: ExtractorFn = (node, result, context) => {
93106

94107
// fills
95108
if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) {
96-
const fills = node.fills.map((fill) => parsePaint(fill, hasChildren)).reverse();
97-
const styleName = getStyleName(node, context, ["fill", "fills"]);
98-
if (styleName) {
99-
context.globalVars.styles[styleName] = fills;
100-
result.fills = styleName;
101-
} else {
102-
result.fills = findOrCreateVar(context.globalVars, fills, "fill");
103-
}
109+
const fills = node.fills
110+
.filter(isVisible)
111+
.map((fill) => parsePaint(fill, hasChildren))
112+
.reverse();
113+
result.fills = registerStyle(node, context, fills, ["fill", "fills"], "fill");
104114
}
105115

106116
// strokes
107117
const strokes = buildSimplifiedStrokes(node, hasChildren);
108118
if (strokes.colors.length) {
109-
const styleName = getStyleName(node, context, ["stroke", "strokes"]);
110-
if (styleName) {
111-
// Only colors are stylable; keep other stroke props on the node
112-
context.globalVars.styles[styleName] = strokes.colors;
113-
result.strokes = styleName;
114-
if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight;
115-
if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes;
116-
if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights;
117-
} else {
118-
result.strokes = findOrCreateVar(context.globalVars, strokes, "stroke");
119-
}
119+
result.strokes = registerStyle(node, context, strokes.colors, ["stroke", "strokes"], "fill");
120+
if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight;
121+
if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes;
122+
if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights;
120123
}
121124

122125
// effects
123126
const effects = buildSimplifiedEffects(node);
124127
if (Object.keys(effects).length) {
125-
const styleName = getStyleName(node, context, ["effect", "effects"]);
126-
if (styleName) {
127-
// Effects styles store only the effect values
128-
context.globalVars.styles[styleName] = effects;
129-
result.effects = styleName;
130-
} else {
131-
result.effects = findOrCreateVar(context.globalVars, effects, "effect");
132-
}
128+
result.effects = registerStyle(node, context, effects, ["effect", "effects"], "effect");
133129
}
134130

135131
// opacity
@@ -168,24 +164,38 @@ export const componentExtractor: ExtractorFn = (node, result, _context) => {
168164
}
169165
};
170166

167+
type StyleMatch = { name: string; id: string };
168+
171169
// Helper to fetch a Figma style name for specific style keys on a node
172-
function getStyleName(
170+
function getStyleMatch(
173171
node: FigmaDocumentNode,
174172
context: TraversalContext,
175173
keys: string[],
176-
): string | undefined {
174+
): StyleMatch | undefined {
177175
if (!hasValue("styles", node)) return undefined;
178176
const styleMap = node.styles as Record<string, string>;
179177
for (const key of keys) {
180178
const styleId = styleMap[key];
181179
if (styleId) {
182180
const meta = context.globalVars.extraStyles?.[styleId];
183-
if (meta?.name) return meta.name;
181+
if (meta?.name) return { name: meta.name, id: styleId };
184182
}
185183
}
186184
return undefined;
187185
}
188186

187+
function resolveStyleKey(
188+
context: TraversalContext,
189+
styleMatch: StyleMatch,
190+
value: StyleTypes,
191+
): string {
192+
const existing = context.globalVars.styles[styleMatch.name];
193+
if (!existing) return styleMatch.name;
194+
if (JSON.stringify(existing) === JSON.stringify(value)) return styleMatch.name;
195+
196+
return `${styleMatch.name} (${styleMatch.id})`;
197+
}
198+
189199
// -------------------- CONVENIENCE COMBINATIONS --------------------
190200

191201
/**

src/tests/tree-walker.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest";
22
import { extractFromDesign } from "~/extractors/node-walker.js";
33
import { allExtractors, collapseSvgContainers } from "~/extractors/built-in.js";
44
import { simplifyRawFigmaObject } from "~/extractors/design-extractor.js";
5-
import type { GetFileResponse } from "@figma/rest-api-spec";
5+
import type { GetFileResponse, Style } from "@figma/rest-api-spec";
66
import type { Node as FigmaNode } from "@figma/rest-api-spec";
7+
import type { GlobalVars } from "~/extractors/types.js";
78

89
// Minimal Figma node factory — only the fields the walker actually reads.
910
// The Figma types are deeply discriminated unions; we cast through unknown
@@ -120,6 +121,77 @@ describe("extractFromDesign", () => {
120121
const fillEntries = Object.entries(globalVars.styles).filter(([key]) => key.startsWith("fill"));
121122
expect(fillEntries).toHaveLength(1);
122123
});
124+
125+
it("deduplicates identical colors used as both fill and stroke", async () => {
126+
const sharedColor = [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }];
127+
128+
// Stroke node first — if strokes used a different prefix, the var would
129+
// be named stroke_* and the fill would reuse it under the wrong prefix.
130+
const strokeNode = makeNode({
131+
id: "8:1",
132+
name: "A",
133+
type: "FRAME",
134+
strokes: sharedColor,
135+
strokeWeight: 1,
136+
});
137+
const fillNode = makeNode({ id: "8:2", name: "B", type: "FRAME", fills: sharedColor });
138+
139+
const { nodes, globalVars } = await extractFromDesign([strokeNode, fillNode], allExtractors);
140+
141+
expect(nodes[0].strokes).toBeDefined();
142+
expect(nodes[1].fills).toBeDefined();
143+
expect(nodes[0].strokes).toBe(nodes[1].fills);
144+
145+
// The shared var should use the fill prefix since stroke colors are
146+
// structurally identical to fill colors in Figma (both are FILL-type styles).
147+
const colorEntries = Object.entries(globalVars.styles).filter(
148+
([, value]) => JSON.stringify(value) === JSON.stringify(["#FF0000"]),
149+
);
150+
expect(colorEntries).toHaveLength(1);
151+
expect(colorEntries[0][0]).toMatch(/^fill_/);
152+
});
153+
154+
it("disambiguates named styles when style names collide", async () => {
155+
const nodeA = makeNode({
156+
id: "7:1",
157+
name: "Text A",
158+
type: "TEXT",
159+
characters: "Hello",
160+
style: { fontFamily: "Inter", fontWeight: 400, fontSize: 12 },
161+
styles: { text: "13:77" },
162+
});
163+
164+
const nodeB = makeNode({
165+
id: "7:2",
166+
name: "Text B",
167+
type: "TEXT",
168+
characters: "World",
169+
style: { fontFamily: "Inter", fontWeight: 600, fontSize: 14 },
170+
styles: { text: "161:300" },
171+
});
172+
173+
const extraStyles: Record<string, Style> = {
174+
"13:77": { name: "Heading / Large" } as Style,
175+
"161:300": { name: "Heading / Large" } as Style,
176+
};
177+
178+
const globalVars = { styles: {}, extraStyles } as GlobalVars;
179+
180+
const { nodes, globalVars: resultVars } = await extractFromDesign(
181+
[nodeA, nodeB],
182+
allExtractors,
183+
{},
184+
globalVars,
185+
);
186+
187+
expect(nodes[0].textStyle).toBe("Heading / Large");
188+
expect(nodes[1].textStyle).toBe("Heading / Large (161:300)");
189+
190+
const styleKeys = Object.keys(resultVars.styles).filter((key) =>
191+
key.startsWith("Heading / Large"),
192+
);
193+
expect(styleKeys).toHaveLength(2);
194+
});
123195
});
124196

125197
describe("collapseSvgContainers", () => {

src/transformers/style.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,11 @@ export function buildSimplifiedStrokes(
234234
): SimplifiedStroke {
235235
let strokes: SimplifiedStroke = { colors: [] };
236236
if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
237-
strokes.colors = n.strokes.filter(isVisible).map((stroke) => parsePaint(stroke, hasChildren));
237+
// Reverse to match CSS stacking order (Figma layers bottom-to-top, CSS top-to-bottom)
238+
strokes.colors = n.strokes
239+
.filter(isVisible)
240+
.map((stroke) => parsePaint(stroke, hasChildren))
241+
.reverse();
238242
}
239243

240244
if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {

0 commit comments

Comments
 (0)