Skip to content

Commit 28405b2

Browse files
committed
feat(grounding): icon_map — reuse curated .svg icons + color contract
Add icon_map, the third grounding join (alongside component_map / token_map): map a Figma icon node to the project's existing .svg files so codegen reuses the designer-curated asset instead of re-exporting a duplicate. - icons/repo-icons.ts: gitignore-aware .svg scan + classifySvgColor (currentColor / fixed / multi-color / unknown, read from the markup) + detectIconLibraries (lucide / heroicons / iconify from deps). - join/icon-map.ts: collectFigmaIcons (name-marker / vector-leaf detection, stop-descend so inner vectors aren't double-counted) + pure joinIcons with a near-exact match floor (0.85) and color-contract recolor guidance gated on profile.svg.mode. - tools/icon-map.ts: orchestrator (reuses get_design_context; no plugin handler), profile gains readProjectDeps. - SKILL + figma_to_code prompt: icon_map-first, then library, then a fresh get_screenshot SVG; color a single-color icon at the usage site. Design decisions, both validated against Design C (52 real icons): - Per-icon claims are verified-only (a matched .svg file); installed icon libraries are surfaced at the result level rather than fabricating a per-icon import we can't verify. - Near-exact floor: a wrong icon is a silent visual bug (up-arrow rendered as a matched down-arrow), so precision beats recall — the A/B caught arr-u→arr-d / checkbox→check mis-maps; the floor drops them to a safe fresh export. End-to-end run on M_home: 15 icons, 0 mis-maps. - No fabricated import string: the usable specifier (an alias or a path relative to the not-yet-written caller) is project-specific, so it's composed by codegen mirroring the repo — like component_map. 593 tests, typecheck/lint/build green.
1 parent 0bc5c1d commit 28405b2

11 files changed

Lines changed: 731 additions & 4 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { readFile } from 'node:fs/promises';
2+
3+
import { walkRepoFiles } from '../repo-walk.js';
4+
5+
// The repo side of the icon join: the project's existing, curated `.svg` files (the ones a designer
6+
// hands over), plus any installed icon component library. Both are read off disk so the join only ever
7+
// claims a reuse it can verify — a matched file actually exists; a library is only surfaced when it's a
8+
// real dependency. The matching itself stays pure in join/icon-map.ts.
9+
10+
/**
11+
* The color contract an icon's fill obeys, read from the SVG's own markup — this decides whether
12+
* codegen may recolor it and how:
13+
*
14+
* - `currentColor`: fills/strokes are `currentColor` → recolorable via the CSS `color` property
15+
* (Tailwind `text-*`), and it inherits from the parent. The single-color, designer-prepped case.
16+
* - `fixed`: exactly one hard-coded color → baked in, not recolorable via CSS.
17+
* - `multi-color`: several colors / a gradient / an embedded raster → render as-is, never recolor.
18+
* - `unknown`: no explicit fill and no `currentColor` → can't tell; codegen should inspect.
19+
*/
20+
export type SvgColorContract = 'currentColor' | 'fixed' | 'multi-color' | 'unknown';
21+
22+
export interface RepoSvg {
23+
/** Repo-relative posix path, e.g. "src/assets/icons/arrow-right.svg". */
24+
path: string;
25+
/** Basename without extension, raw (e.g. "arrow-right", "ic_search"). */
26+
fileName: string;
27+
colorContract: SvgColorContract;
28+
}
29+
30+
// Pull every fill/stroke color the markup commits to: presentation attributes (`fill="#111"`) and
31+
// inline styles (`style="fill:#111"`). url(#…) references (gradients) and rasters are handled
32+
// separately as a hard multi-color signal.
33+
const COLOR_TOKEN = /(?:fill|stroke)\s*[:=]\s*["']?\s*([^"';\s/>]+)/gi;
34+
const GRADIENT_OR_IMAGE = /<(?:linear|radial)gradient|url\(#|<image\b/i;
35+
36+
const isNoColor = (v: string): boolean => {
37+
const c = v.toLowerCase();
38+
return c === 'none' || c === 'transparent' || c === 'inherit';
39+
};
40+
41+
/** Classify an SVG's color contract from its markup. Conservative: `currentColor` wins outright. */
42+
export const classifySvgColor = (svg: string): SvgColorContract => {
43+
const colors = new Set<string>();
44+
let sawCurrentColor = false;
45+
for (const m of svg.matchAll(COLOR_TOKEN)) {
46+
const value = (m[1] ?? '').toLowerCase();
47+
if (value === 'currentcolor') sawCurrentColor = true;
48+
else if (!isNoColor(value)) colors.add(value);
49+
}
50+
// A loader/designer convention is to drive a single-color icon entirely off currentColor; any
51+
// currentColor presence means the icon is meant to be recolored by inherited color.
52+
if (sawCurrentColor) return 'currentColor';
53+
if (GRADIENT_OR_IMAGE.test(svg)) return 'multi-color';
54+
if (colors.size === 0) return 'unknown';
55+
return colors.size === 1 ? 'fixed' : 'multi-color';
56+
};
57+
58+
/**
59+
* Scan the project for curated `.svg` icon files (gitignore-aware), reading each one's color
60+
* contract.
61+
*/
62+
export const scanRepoSvgs = async (rootDir: string): Promise<RepoSvg[]> => {
63+
const out: RepoSvg[] = [];
64+
for await (const path of walkRepoFiles(rootDir, { extensions: ['.svg'] })) {
65+
let content: string;
66+
try {
67+
// eslint-disable-next-line no-await-in-loop -- bounded by the walker's cap; clarity over batching
68+
content = await readFile(`${rootDir}/${path}`, 'utf8');
69+
} catch {
70+
continue;
71+
}
72+
const base = path.split('/').pop() ?? path;
73+
out.push({
74+
path,
75+
fileName: base.replace(/\.svg$/i, ''),
76+
colorContract: classifySvgColor(content),
77+
});
78+
}
79+
return out;
80+
};
81+
82+
// Icon component libraries: when one is installed, an unmapped Figma icon can be imported from it
83+
// instead of exported fresh. We only *report which libraries are present* (verifiable from deps) and
84+
// never fabricate a per-icon import — we can't confirm a given package actually exports a given icon
85+
// without resolving it, and a hallucinated `import { Foo } from 'lucide-react'` is worse than an
86+
// honest "export it". Ordered most- to least-common so the report is stable.
87+
const ICON_LIBRARY_DEPS: readonly string[] = [
88+
'lucide-react',
89+
'lucide-vue-next',
90+
'@tabler/icons-react',
91+
'@tabler/icons-vue',
92+
'@heroicons/react',
93+
'@heroicons/vue',
94+
'@phosphor-icons/react',
95+
'@phosphor-icons/vue',
96+
'react-icons',
97+
'react-feather',
98+
'@radix-ui/react-icons',
99+
'unplugin-icons', // iconify-backed: import from `~icons/{collection}/{name}`
100+
];
101+
102+
/** Detected icon component libraries (dep names) present in the project, in report order. */
103+
export const detectIconLibraries = (deps: Record<string, string>): string[] =>
104+
ICON_LIBRARY_DEPS.filter(dep => dep in deps);

packages/server/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { PROMPTS } from './prompts/registry.js';
1717
import { ANALYZE_PROJECT_TOOL_NAME, handleAnalyzeProject } from './tools/analyze-project.js';
1818
import { COMPONENT_MAP_TOOL_NAME, handleComponentMap } from './tools/component-map.js';
1919
import { GET_SCREENSHOT_TOOL_NAME, screenshotContent } from './tools/get-screenshot.js';
20+
import { handleIconMap, ICON_MAP_TOOL_NAME } from './tools/icon-map.js';
2021
import { formatPingResult, handlePing, pingTool } from './tools/ping.js';
2122
import { ALL_TOOL_SPECS } from './tools/registry.js';
2223
import { handleSaveScreenshots, SAVE_SCREENSHOTS_TOOL_NAME } from './tools/save-screenshots.js';
@@ -101,6 +102,7 @@ const SPECIAL_HANDLERS: Record<string, ToolHandler> = {
101102
[COMPONENT_MAP_TOOL_NAME]: async args =>
102103
textResult(await handleComponentMap(await routedDispatch(), args)),
103104
[TOKEN_MAP_TOOL_NAME]: async args => textResult(await handleTokenMap(dispatch, args)),
105+
[ICON_MAP_TOOL_NAME]: async args => textResult(await handleIconMap(await routedDispatch(), args)),
104106
};
105107

106108
// Reversible writes that destroy data — surfaced via the destructiveHint annotation. Other writes
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import type { DesignContextNode, SerializedPaint } from '@figma-mcp-relay/shared';
2+
3+
import type { RepoSvg, SvgColorContract } from '../icons/repo-icons.js';
4+
import type { ProjectProfile } from '../profile/profile.js';
5+
import { diceSimilarity, type MappingStatus } from './component-map.js';
6+
7+
// The icon join: a Figma icon node → an existing project `.svg` file, so codegen reuses the designer's
8+
// curated asset instead of re-exporting a duplicate. Like the component/token joins it's name-based and
9+
// pure (no Figma, no fs). It carries two extra dimensions the other joins don't need:
10+
// - a *color contract* (read from the matched file) deciding whether/how the icon may be recolored, and
11+
// - whether recoloring is even possible given how the project imports svg (currentColor dies through
12+
// an <img> in url mode), so a wrong recolor instruction is caught at grounding time.
13+
// Per-icon it only ever claims a *verified* file match; the library route (lucide / iconify) is surfaced
14+
// at the result level by the tool, not fabricated here.
15+
16+
const ICON_MARKER = /(^|[\s/_-])ic(on)?s?([\s/_-]|$)/i;
17+
// Figma's default names for vector art that isn't a named, reusable icon — excluded so a decorative
18+
// stroke ("Vector 3") doesn't read as a missing icon. A real icon is named (search, arrow-right).
19+
const DEFAULT_VECTOR_NAME =
20+
/^(vector|union|subtract|intersect|exclude|rectangle|ellipse|line|polygon|star|group|frame|shape|path|oval|boolean)\s*\d*$/i;
21+
const VECTOR_TYPES = new Set(['VECTOR', 'BOOLEAN_OPERATION']);
22+
23+
const carriesMarker = (node: DesignContextNode): boolean =>
24+
ICON_MARKER.test(node.name) ||
25+
(node.mainComponent !== undefined &&
26+
(ICON_MARKER.test(node.mainComponent.name) ||
27+
(node.mainComponent.componentSetName !== undefined &&
28+
ICON_MARKER.test(node.mainComponent.componentSetName))));
29+
30+
/** The label to match on: the set/main name for an instance, else the node name. */
31+
const sourceName = (node: DesignContextNode): string =>
32+
node.mainComponent?.componentSetName ?? node.mainComponent?.name ?? node.name;
33+
34+
/**
35+
* Strip the icon decoration off a name → the bare icon label ("Icons/ic_arrow-right" →
36+
* "arrow-right").
37+
*/
38+
export const iconLabel = (raw: string): string => {
39+
const segs = raw
40+
.split('/')
41+
.map(s => s.trim())
42+
.filter(Boolean);
43+
while (segs.length > 1 && /^ic(on)?s?$/i.test(segs[0] as string)) segs.shift();
44+
const last = segs[segs.length - 1] ?? raw;
45+
return last.replace(/^ic(on)?s?[-_]/i, '').trim();
46+
};
47+
48+
const norm = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, '');
49+
50+
const hex2 = (n: number): string =>
51+
Math.round(Math.max(0, Math.min(1, n)) * 255)
52+
.toString(16)
53+
.padStart(2, '0');
54+
55+
const toHex = (c: { r: number; g: number; b: number }): string =>
56+
`#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`.toUpperCase();
57+
58+
/** The icon's intended color at the usage site: a solid fill hex and/or a bound variable. */
59+
const fillOf = (node: DesignContextNode): IconMapping['fill'] => {
60+
const out: { hex?: string; variable?: boolean } = {};
61+
const fills = node.fills;
62+
if (Array.isArray(fills)) {
63+
const solid = (fills as SerializedPaint[]).find(p => p.type === 'SOLID' && p.visible !== false);
64+
if (solid?.type === 'SOLID') out.hex = toHex(solid.color);
65+
}
66+
if (node.boundVariables?.fills !== undefined && node.boundVariables.fills.length > 0)
67+
out.variable = true;
68+
return out.hex === undefined && out.variable === undefined ? undefined : out;
69+
};
70+
71+
export interface FigmaIconUsage {
72+
/** Bare icon label used for the join + display (decoration stripped). */
73+
name: string;
74+
/** The original Figma node name, kept for the report. */
75+
figmaName: string;
76+
nodeIds: string[];
77+
fill?: { hex?: string; variable?: boolean };
78+
}
79+
80+
/**
81+
* Walk the grounded trees and collect icon usages, grouped by label so an icon used N times is one
82+
* row with N node ids. An icon "root" is recorded without recursing into it, so the vectors inside
83+
* a named icon aren't double-counted. Detection (name-marker / vector-leaf with a meaningful name)
84+
* is deliberately a best guess — a false positive just yields an `unmapped` row the agent can
85+
* ignore, while the alternative (missing reuse) is the costlier error.
86+
*/
87+
export const collectFigmaIcons = (roots: readonly DesignContextNode[]): FigmaIconUsage[] => {
88+
const byKey = new Map<string, FigmaIconUsage>();
89+
90+
const record = (node: DesignContextNode): void => {
91+
const figmaName = sourceName(node);
92+
const label = iconLabel(figmaName);
93+
if (label === '') return;
94+
const key = norm(label);
95+
const usage: FigmaIconUsage = byKey.get(key) ?? { name: label, figmaName, nodeIds: [] };
96+
usage.nodeIds.push(node.id);
97+
const fill = fillOf(node);
98+
if (usage.fill === undefined && fill !== undefined) usage.fill = fill;
99+
byKey.set(key, usage);
100+
};
101+
102+
const visit = (node: DesignContextNode): void => {
103+
const marker = carriesMarker(node);
104+
const isVectorLeaf = VECTOR_TYPES.has(node.type);
105+
const isInstanceLike = node.type === 'INSTANCE' || node.type === 'COMPONENT';
106+
107+
// An icon root we record and stop descending into.
108+
if (
109+
(isInstanceLike && marker) ||
110+
marker ||
111+
(isVectorLeaf && !DEFAULT_VECTOR_NAME.test(node.name.trim()))
112+
) {
113+
record(node);
114+
return;
115+
}
116+
for (const child of node.children ?? []) visit(child);
117+
};
118+
119+
for (const root of roots) visit(root);
120+
return [...byKey.values()];
121+
};
122+
123+
export interface IconMapping {
124+
figmaName: string;
125+
name: string;
126+
nodeIds: string[];
127+
fill?: { hex?: string; variable?: boolean };
128+
candidate?: {
129+
/** Repo-relative path of the matched `.svg`. */
130+
filePath: string;
131+
colorContract: SvgColorContract;
132+
/** How to color it in this project, grounded off the contract + svg mode + styling system. */
133+
recolor: string;
134+
confidence: number;
135+
};
136+
status: MappingStatus;
137+
}
138+
139+
// Deliberately no fabricated `import` string. The loader form (?react / ?component / { ReactComponent }
140+
// / url) is already returned at the result level on profile.svg.importHint, and the actual specifier —
141+
// an alias like @/assets/… or a path relative to the *consuming* file — depends on the project's alias
142+
// config (tsconfig paths / vite resolve.alias, a long tail) and the not-yet-written caller's location,
143+
// which the join can't know. Precomputing it would mean guessing, and a confidently-wrong import is
144+
// worse than none. So, like component_map (which only returns filePath), we ground the file + loader
145+
// form + color contract and let codegen compose the import, mirroring the project's existing imports.
146+
147+
/** How to recolor, grounded off the file's contract and whether this project's svg mode allows it. */
148+
const recolorGuidance = (
149+
contract: SvgColorContract,
150+
svg: ProjectProfile['svg'],
151+
tailwind: boolean,
152+
): string => {
153+
const colorClass = tailwind ? 'text-{token}' : 'the CSS `color` property (or var(--token))';
154+
switch (contract) {
155+
case 'currentColor':
156+
return svg.mode === 'component'
157+
? `recolorable — set ${colorClass} at the usage site (fill is currentColor); it also inherits the parent's text color`
158+
: `currentColor can't apply through an <img> (url mode) — inline the svg or add an svg loader to recolor, else it renders with no color`;
159+
case 'fixed':
160+
return 'single fixed color baked into the file — not recolorable via CSS; if the Figma fill differs, convert its fills to currentColor or re-export';
161+
case 'multi-color':
162+
return 'multi-color asset — render as-is, do not recolor';
163+
case 'unknown':
164+
return 'color contract unclear (no explicit fill or currentColor found) — inspect the svg before relying on recolor';
165+
}
166+
};
167+
168+
const statusFor = (confidence: number, threshold: number): MappingStatus => {
169+
if (confidence >= 0.85) return 'high';
170+
if (confidence >= threshold) return 'medium';
171+
if (confidence >= 0.5) return 'low';
172+
return 'unmapped';
173+
};
174+
175+
export interface IconJoinOptions {
176+
threshold: number;
177+
svg: ProjectProfile['svg'];
178+
tailwind: boolean;
179+
}
180+
181+
// Icons demand near-exact matching, unlike the component join. The asymmetry is the reason: a wrong
182+
// icon file is a *silent visual bug* (an up-arrow rendered as the matched down-arrow), while a missed
183+
// match just re-exports the correct icon fresh — so precision beats recall. Raw Dice can't separate a
184+
// real typo ("inifinte"→infinite, 0.71) from a wrong neighbor ("checkbox"→check 0.73, "arr-u"→arr-d
185+
// 0.67, "cash"→trash 0.57) — they overlap — and the wrong ones are the costlier error, so the floor
186+
// is set at the high bar: only an exact (after separator/space normalization) or long-name one-char
187+
// match reuses a file; everything else falls through to a fresh export. (Real synonyms like
188+
// trash↔delete need a synonym table, not fuzz — a future step, same as the token join's.)
189+
const ICON_MATCH_FLOOR = 0.85;
190+
191+
const bestSvgMatch = (
192+
label: string,
193+
svgs: readonly RepoSvg[],
194+
): { svg: RepoSvg; score: number } | null => {
195+
const target = norm(label);
196+
let best: { svg: RepoSvg; score: number } | null = null;
197+
for (const svg of svgs) {
198+
const score = Math.max(
199+
diceSimilarity(target, norm(svg.fileName)),
200+
diceSimilarity(target, norm(iconLabel(svg.fileName))),
201+
);
202+
if (best === null || score > best.score) best = { svg, score };
203+
}
204+
return best;
205+
};
206+
207+
const joinOne = (
208+
icon: FigmaIconUsage,
209+
svgs: readonly RepoSvg[],
210+
opts: IconJoinOptions,
211+
): IconMapping => {
212+
const base: IconMapping = {
213+
figmaName: icon.figmaName,
214+
name: icon.name,
215+
nodeIds: icon.nodeIds,
216+
...(icon.fill === undefined ? {} : { fill: icon.fill }),
217+
status: 'unmapped',
218+
};
219+
220+
const match = bestSvgMatch(icon.name, svgs);
221+
if (match === null || match.score < ICON_MATCH_FLOOR) return base;
222+
223+
const confidence = Number(match.score.toFixed(3));
224+
return {
225+
...base,
226+
candidate: {
227+
filePath: match.svg.path,
228+
colorContract: match.svg.colorContract,
229+
recolor: recolorGuidance(match.svg.colorContract, opts.svg, opts.tailwind),
230+
confidence,
231+
},
232+
status: statusFor(confidence, opts.threshold),
233+
};
234+
};
235+
236+
/** Join every Figma icon usage against the project's `.svg` files; pure over its inputs. */
237+
export const joinIcons = (
238+
icons: readonly FigmaIconUsage[],
239+
svgs: readonly RepoSvg[],
240+
opts: IconJoinOptions,
241+
): IconMapping[] => icons.map(i => joinOne(i, svgs, opts));

packages/server/src/profile/profile.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,10 @@ const allDeps = (pkg: PackageJson | null): Record<string, string> => ({
166166
...pkg?.devDependencies,
167167
});
168168

169+
/** All dependencies (prod + dev) declared in the project's package.json, or {} when absent. */
170+
export const readProjectDeps = async (rootDir: string): Promise<Record<string, string>> =>
171+
allDeps(await readJson<PackageJson>(join(resolve(rootDir), 'package.json')));
172+
169173
/** Parse the leading major version out of a semver range like "^4.0.0" or "~3.4.1". */
170174
const parseMajor = (range: string | undefined): number | undefined => {
171175
if (range === undefined) return undefined;

0 commit comments

Comments
 (0)