Add MCP codegen pipeline: tools, prompt, and plan

This commit is contained in:
Anton A S 2026-03-10 22:21:21 +03:00
parent 9f98833495
commit 31dbe9690c
7 changed files with 1259 additions and 0 deletions

View file

@ -0,0 +1,244 @@
# Design → Code Pipeline: Overview
## Goal
Take a .fig design and produce production-ready code on the user's target stack (React, Vue, Svelte, etc. + Tailwind/CSS Modules/etc.) via AI-assisted code generation through the MCP server.
## Current State
### What exists (extraction layer)
| Capability | Tool/Module | Output |
|-----------|-------------|--------|
| Read node tree | `get_page_tree`, `get_node`, `find_nodes`, `query_nodes` | JSON node properties |
| Semantic analysis | `describe` | Role, layout, visual style, issues |
| JSX representation | `get_jsx` | OpenPencil JSX (`<Frame flex="col" w={320}>`) |
| Tailwind JSX | `export-jsx.ts` (tailwind format) | `<div className="flex flex-col w-80">` |
| SVG export | `export_svg` | SVG markup string |
| Image export | `export_image` | PNG/JPG/WEBP raster |
| Components list | `get_components` | Component IDs, names, pages |
| Design tokens | `list_variables`, `find_variables`, `list_collections` | Variable names, types, values, modes |
| Color analysis | `analyze_colors` | Palette, frequencies, variable bindings, similar clusters |
| Typography analysis | `analyze_typography` | Font families, sizes, weights, frequencies |
| Spacing analysis | `analyze_spacing` | Gaps, paddings, grid compliance |
| Pattern detection | `analyze_clusters` | Repeated structures → potential components |
| Structural diff | `diff_jsx`, `diff_create` | Unified diff between two nodes |
| XPath queries | `query_nodes` | `//FRAME[@width < 300]`, `//TEXT[contains(@text, 'Hello')]` |
### What does NOT exist
1. **System/instructions prompt for code generation** — no guidance for the AI on how to convert design → code
2. **Component decomposition**`analyze_clusters` finds repeated patterns, but doesn't determine component boundaries, props, variants, slots
3. **Design token → CSS variable mapping**`list_variables` returns raw Figma variables, but nothing maps them to `--color-primary`, `var(--spacing-4)`, etc.
4. **Target stack awareness** — no concept of "this project uses Vue 3 + Tailwind" vs "React + CSS Modules"
5. **Production JSX output**`export-jsx.ts` tailwind format produces unstyled `<div>` soup without component structure, prop interfaces, or framework idioms
6. **Verification** — no way to compare generated code output against the design visually
---
## Architecture
```
.fig design file
┌──────────────────────────────────────────────────────────────┐
│ PHASE 1: EXTRACTION (tools exist) │
│ │
│ get_page_tree → full structure │
│ get_components → component inventory │
│ list_variables → design tokens │
│ analyze_colors/typography/spacing → design system snapshot │
│ analyze_clusters → repeated patterns │
│ describe → semantic roles per node │
│ get_jsx → structural JSX │
│ export_svg → vector assets │
│ export_image → screenshots for verification │
└──────────────┬───────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ PHASE 2: DECOMPOSITION (needs: prompt + possibly tools) │
│ │
│ Which nodes are screens vs components vs primitives? │
│ What props does each component accept? │
│ Which components have variants (state, size, theme)? │
│ Which design variables map to which CSS tokens? │
│ What's the component hierarchy / dependency graph? │
└──────────────┬───────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ PHASE 3: CODE GENERATION (needs: prompt) │
│ │
│ Generate component files on target stack │
│ Map design tokens → CSS/theme variables │
│ Extract SVG assets for icons/illustrations │
│ Wire up component hierarchy and props │
│ Match typography, spacing, colors exactly │
└──────────────┬───────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ PHASE 4: VERIFICATION (needs: prompt guidance) │
│ │
│ Compare generated code visually against design │
│ Check token coverage, missing styles │
│ Verify responsive behavior │
└──────────────────────────────────────────────────────────────┘
```
---
## What to Build
### 1. MCP System Prompt for Code Generation
A system prompt served when AI is asked to generate code from a design. Not the same as the design-chat prompt in `use-chat.ts`. This one instructs the AI to use extraction tools, decompose the design, and output production code.
**Content outline:**
```
You are a frontend engineer generating production code from Figma designs.
# Workflow
1. UNDERSTAND the design
- get_page_tree → scan structure
- get_components → inventory reusable parts
- list_variables + list_collections → design tokens
- analyze_colors, analyze_typography, analyze_spacing → design system snapshot
2. PLAN the component tree
- analyze_clusters → find repeated patterns
- describe on key nodes → semantic roles
- Determine: which frames are pages/screens, which are components, which are primitives
- Map Figma components → code components
- Identify props: text content, colors, sizes, visibility, children (slots)
- Identify variants: if component has states (hover, active, disabled), map to props
3. EXTRACT design tokens
- list_variables → get all variables with values per mode (light/dark)
- Map to CSS custom properties or theme object
- Color variables → --color-{name}
- Number variables → --spacing-{name}, --radius-{name}, etc.
- Fonts → font-family definitions
4. GENERATE code
- One component per file
- Use get_jsx on each component to read structure
- Use export_svg for vector icons/illustrations
- Apply design tokens as CSS variables / theme references
- Match pixel values exactly: font sizes, spacing, radii, colors
- Framework-specific:
- React: functional components, TypeScript props interface, named exports
- Vue: <script setup lang="ts">, defineProps, <template>
- Svelte: $props, <script lang="ts">
- Styling:
- Tailwind: utility classes, arbitrary values for exact matches
- CSS Modules: .module.css with variables
- Styled-components: tagged templates with theme
5. VERIFY
- Re-read the design with describe
- Compare against generated code structure
- Check: all text content matches, all colors use tokens, spacing is correct
- List any deviations
```
### 2. Tool: `design_to_tokens`
Automates Phase 2 token mapping. Could be a tool or a prompt-guided workflow.
```typescript
defineTool({
name: 'design_to_tokens',
description: 'Extract design tokens as CSS custom properties from Figma variables.',
params: {
format: { type: 'string', enum: ['css', 'tailwind', 'json'], description: 'Output format' }
},
execute: (figma, args) => {
const vars = figma.getLocalVariables()
const collections = figma.getLocalVariableCollections()
// Map variables to CSS custom properties
// Group by collection → mode → variable
// Output :root { --color-primary: #3b82f6; ... }
// Or tailwind.config.ts theme extension
// Or JSON token file
}
})
```
### 3. Tool: `design_to_component_map`
Automates Phase 2 decomposition. Analyzes the document and returns a structured component map.
```typescript
defineTool({
name: 'design_to_component_map',
description: 'Analyze document structure and return a component decomposition map.',
params: {
page: { type: 'string', description: 'Page name to analyze' },
depth: { type: 'number', description: 'Max nesting depth (default: 3)' }
},
execute: (figma, args) => {
// 1. Get all COMPONENT/COMPONENT_SET nodes
// 2. For each, analyze: name, variants, instance count, props (overridden fields)
// 3. Get all top-level frames that aren't components → these are screens/pages
// 4. For each screen, walk tree and record which components are used where
// Return:
// {
// components: [{ id, name, variants, props, instanceCount, usedIn }],
// screens: [{ id, name, components: [refs] }],
// tokens: { colors: [...], typography: [...], spacing: [...] }
// }
}
})
```
### 4. Enhanced `get_jsx` with Production Format
Add a third format to `export-jsx.ts` that outputs framework-aware code:
```typescript
// format: 'react' | 'vue' | 'svelte'
// Uses component names from Figma, maps design tokens, adds prop interfaces
```
Or this could be entirely prompt-driven, using existing `get_jsx` output as input and letting the AI transform it.
### 5. MCP Prompt File Serving
The MCP server needs a way to serve the code generation prompt. Options:
**Option A: Bake into system prompt** — The MCP server's `createServer()` sets instructions in server metadata. External AI clients (Claude Code, Cursor, etc.) receive it automatically.
**Option B: Dedicated tool** — `get_codegen_guidelines` tool that returns the prompt text. AI calls it when code generation is requested.
**Option C: MCP Resource** — Serve as an MCP resource (`prompts/codegen`) that clients can read.
Recommendation: **Option A** — system prompt in MCP server metadata + **Option B** as fallback for clients that don't read server instructions.
---
## What NOT to Build
1. **Framework-specific design-generation prompts** — those are for generating designs, not code. We already have the design-chat prompt.
2. **Batch operation DSL** — we have `render` with JSX + 88 atomic tools, no need for a custom batch language.
3. **Hardcoded framework templates** — Don't bake React/Vue/Svelte templates into tools. Let the AI generate idiomatic code guided by the prompt. The prompt tells it the target stack; the AI writes the code.
4. **Style guide / inspiration system** — That's for design generation, not code generation. Out of scope.
---
## Implementation Order
1. **Write the system prompt** — the code generation instruction document. Test with MCP + Claude Code on a real .fig file.
2. **Add `design_to_tokens` tool** — deterministic token extraction, CSS/Tailwind/JSON output.
3. **Add `design_to_component_map` tool** — structural component decomposition.
4. **Integrate prompt into MCP server** — serve via server instructions + tool fallback.
5. **Test & iterate** — run on real designs, evaluate output quality.
Step 1 is the highest leverage: a good prompt with existing tools will already produce usable code. Steps 2-3 improve quality by reducing AI guesswork.

97
docs/mcp-codegen-plan.md Normal file
View file

@ -0,0 +1,97 @@
# MCP Codegen Pipeline — Implementation Plan
## What's Done
Branch `feat/mcp-codegen` (off `57eaf87`).
### New Files
- `packages/core/src/tools/codegen.ts` — 3 MCP tools:
- `design_to_tokens` — extracts Figma variables as CSS/Tailwind/JSON, resolves aliases, handles multi-mode
- `design_to_component_map` — analyzes document for screens, components (variants, props, instance counts), dependency overview
- `get_codegen_prompt` — returns the codegen system prompt (lazy `await import('node:fs/promises')` to avoid Vite browser externalization error)
- `packages/core/src/tools/prompts/codegen.md` — 5-step workflow prompt (~8KB):
1. Survey (page tree, components, variables, colors, typography, spacing)
2. Decompose (clusters, describe, get_jsx → component map)
3. Extract tokens (stack-aware: Tailwind → only semantic color CSS vars; CSS → full vars)
4. Generate code (bottom-up, one file per component, interactive states)
5. Verify (re-check against design)
- `packages/core/src/tools/registry.ts` — 3 tools registered in `ALL_TOOLS` (now 91 total)
- `docs/design-to-code-overview.md` — architecture overview
### Design Decisions
- **No MCP server instructions** — prompt delivered via `get_codegen_prompt` tool only (no 8KB overhead per request)
- **Lazy fs import**`await import('node:fs/promises')` inside function body to avoid Vite `Module "node:fs" has been externalized` crash in browser bundle
- **Tailwind token strategy** — CSS custom properties only for semantic colors; Tailwind utilities for spacing/font/radius (avoids `--font-bold`, `--text-sm` conflicts with Tailwind v4 internals)
- **No modifications to existing tools**`get_jsx`, `describe`, `analyze_*` used as-is
### Tested
- MCP HTTP server (`http://127.0.0.1:3100/mcp`) — all 3 new tools callable
- End-to-end with `lol.fig` (Movie Card, 390×1823, Inter font, dark theme, 164 nodes):
- Full pipeline: survey → decompose → tokens → generate → verify
- Generated 10 Vue 3 + Tailwind components + semantic color tokens
- Visual test in just-bun dev server
## What's Left
### Phase 1 — Tailwind HTML Export (high value, low effort)
Add `format` parameter to `get_jsx` tool:
- `"openpencil"` (default) — current round-trip JSX
- `"tailwind"` — HTML with Tailwind v4 utility classes
The renderer already exists (`export-jsx.ts` → `collectTailwindClasses`), just not exposed through the tool params. One change in `read.ts`.
This gives the LLM ready-to-use HTML with correct Tailwind classes directly from MCP — drastically reduces hallucination in generated code.
### Phase 2 — Prompt Improvements
- [ ] **Multi-file output format** — prompt should specify how to structure output when generating multiple files (fenced blocks with filenames)
- [ ] **Framework adapters** — framework-specific sections in prompt (Vue `<script setup>` boilerplate, React hooks patterns, Svelte `$props()`)
- [ ] **Image handling** — prompt guidance on when to use `export_svg` vs `export_png`, how to reference assets in generated code
- [ ] **Responsive** — breakpoint detection from frame widths, responsive class generation
### Phase 3 — Advanced Tools
- [ ] **`design_to_html`** — full page HTML + Tailwind from a frame, using the existing tailwind renderer + semantic grouping. Essentially `get_jsx format=tailwind` but with smarter HTML tag selection (nav, header, main, section, article, button)
- [ ] **`design_to_styles`** — extract complete stylesheet (CSS custom properties for all tokens, not just Figma variables but also inferred from analyze_colors/typography)
- [ ] **`compare_design_code`** — given generated code and node ID, produce a diff of what's missing or wrong
### Phase 4 — Integration
- [ ] Pi skill for open-pencil MCP (`~/.pi/agent/skills/open-pencil/SKILL.md`) — DONE, needs testing with real workflow
- [ ] VS Code extension integration — connect open-pencil MCP to the extension's AI chat
- [ ] Batch mode — process multiple frames/pages in one go
## Architecture
```
┌─────────────────────┐
│ AI Agent (LLM) │
│ │
│ reads codegen.md │
│ calls MCP tools │
│ generates code │
└─────────┬───────────┘
│ MCP (HTTP SSE)
┌─────────▼───────────┐
│ MCP Server │
│ :3100/mcp │
├─────────────────────┤
│ Survey tools │ get_page_tree, get_components,
│ │ list_variables, list_collections,
│ │ analyze_colors, analyze_typography,
│ │ analyze_spacing
├─────────────────────┤
│ Decompose tools │ analyze_clusters, describe, get_jsx,
│ │ design_to_component_map
├─────────────────────┤
│ Token tools │ design_to_tokens, list_variables
├─────────────────────┤
│ Codegen tools │ get_codegen_prompt, export_svg
├─────────────────────┤
│ Core │ .fig parser, scene graph, renderers
└─────────────────────┘
```

BIN
lol.fig Normal file

Binary file not shown.

View file

@ -0,0 +1,446 @@
import { colorToHex } from '../color'
import { defineTool } from './schema'
import type { Color } from '../types'
import type { FigmaAPI } from '../figma-api'
import type { Variable, VariableCollection, VariableValue, SceneNode } from '../scene-graph'
function slugify(name: string): string {
return name
.replace(/\//g, '-')
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.toLowerCase()
}
function toCamelCase(name: string): string {
return slugify(name).replace(/-([a-z])/g, (_, c) => c.toUpperCase())
}
function isColor(value: VariableValue): value is Color {
return typeof value === 'object' && value !== null && 'r' in value && 'g' in value && 'b' in value
}
function isAlias(value: VariableValue): value is { aliasId: string } {
return typeof value === 'object' && value !== null && 'aliasId' in value
}
function resolveValue(
value: VariableValue,
variables: Map<string, Variable>,
visited = new Set<string>()
): VariableValue {
if (!isAlias(value)) return value
if (visited.has(value.aliasId)) return value
visited.add(value.aliasId)
const target = variables.get(value.aliasId)
if (!target) return value
const modeId = Object.keys(target.valuesByMode)[0]
if (!modeId) return value
return resolveValue(target.valuesByMode[modeId], variables, visited)
}
function formatCSSValue(value: VariableValue, variables: Map<string, Variable>): string {
const resolved = resolveValue(value, variables)
if (isColor(resolved)) return colorToHex(resolved)
if (typeof resolved === 'number') return String(resolved)
if (typeof resolved === 'string') return resolved
if (typeof resolved === 'boolean') return resolved ? '1' : '0'
if (isAlias(resolved)) return `/* unresolved alias: ${resolved.aliasId} */`
return String(resolved)
}
interface TokenEntry {
name: string
cssVar: string
type: string
values: Record<string, string>
}
function buildTokens(
variables: Variable[],
collections: VariableCollection[],
allVars: Map<string, Variable>
): { tokens: TokenEntry[]; modes: { id: string; name: string; collectionName: string }[] } {
const collectionMap = new Map<string, VariableCollection>()
for (const c of collections) collectionMap.set(c.id, c)
const modes: { id: string; name: string; collectionName: string }[] = []
const seenModes = new Set<string>()
for (const c of collections) {
for (const m of c.modes) {
if (!seenModes.has(m.modeId)) {
seenModes.add(m.modeId)
modes.push({ id: m.modeId, name: m.name, collectionName: c.name })
}
}
}
const tokens: TokenEntry[] = []
for (const v of variables) {
const col = collectionMap.get(v.collectionId)
const prefix = col ? slugify(col.name) : 'token'
const cssVar = `--${prefix}-${slugify(v.name)}`
const values: Record<string, string> = {}
for (const [modeId, val] of Object.entries(v.valuesByMode)) {
values[modeId] = formatCSSValue(val, allVars)
}
tokens.push({ name: v.name, cssVar, type: v.type, values })
}
return { tokens, modes }
}
function renderCSS(
tokens: TokenEntry[],
modes: { id: string; name: string; collectionName: string }[]
): string {
if (tokens.length === 0) return '/* No design tokens found */\n'
const defaultModeId = modes[0]?.id
const lines: string[] = [':root {']
for (const t of tokens) {
const val = t.values[defaultModeId ?? ''] ?? Object.values(t.values)[0] ?? ''
lines.push(` ${t.cssVar}: ${val};`)
}
lines.push('}')
for (const mode of modes.slice(1)) {
const className = slugify(mode.name)
lines.push('')
lines.push(`/* ${mode.collectionName} / ${mode.name} */`)
lines.push(`.${className} {`)
for (const t of tokens) {
const val = t.values[mode.id]
if (val !== undefined) lines.push(` ${t.cssVar}: ${val};`)
}
lines.push('}')
}
return lines.join('\n') + '\n'
}
function renderTailwindTheme(
tokens: TokenEntry[],
modes: { id: string; name: string }[]
): string {
const defaultModeId = modes[0]?.id
const colors: Record<string, string> = {}
const spacing: Record<string, string> = {}
const other: Record<string, string> = {}
for (const t of tokens) {
const key = toCamelCase(t.name)
const val = t.values[defaultModeId ?? ''] ?? Object.values(t.values)[0] ?? ''
if (t.type === 'COLOR') colors[key] = val
else if (t.type === 'FLOAT') spacing[key] = `${val}px`
else other[key] = val
}
const theme: Record<string, unknown> = {}
if (Object.keys(colors).length > 0) theme.colors = colors
if (Object.keys(spacing).length > 0) theme.spacing = spacing
return `// Auto-extracted from Figma design tokens
export const designTokens = ${JSON.stringify(theme, null, 2)} as const
`
}
function renderJSON(
tokens: TokenEntry[],
modes: { id: string; name: string }[]
): string {
const result: Record<string, Record<string, string>> = {}
for (const mode of modes) {
const modeTokens: Record<string, string> = {}
for (const t of tokens) {
const val = t.values[mode.id]
if (val !== undefined) modeTokens[t.cssVar] = val
}
result[mode.name] = modeTokens
}
return JSON.stringify(result, null, 2)
}
export const designToTokens = defineTool({
name: 'design_to_tokens',
description:
'Extract design tokens from Figma variables as CSS custom properties, Tailwind theme config, or JSON. Resolves aliases, handles multiple modes (light/dark).',
params: {
format: {
type: 'string',
description: 'Output format',
enum: ['css', 'tailwind', 'json'],
default: 'css'
},
collection: {
type: 'string',
description: 'Filter by collection name (substring, case-insensitive)'
},
type: {
type: 'string',
description: 'Filter by variable type',
enum: ['COLOR', 'FLOAT', 'STRING', 'BOOLEAN']
}
},
execute: (figma, args) => {
const format = args.format ?? 'css'
let variables = figma.getLocalVariables()
const collections = figma.getLocalVariableCollections()
const allVars = new Map<string, Variable>()
for (const v of variables) allVars.set(v.id, v)
if (args.collection) {
const q = args.collection.toLowerCase()
const matchingIds = new Set(
collections.filter((c) => c.name.toLowerCase().includes(q)).map((c) => c.id)
)
variables = variables.filter((v) => matchingIds.has(v.collectionId))
}
if (args.type) {
variables = variables.filter((v) => v.type === args.type)
}
if (variables.length === 0) {
return { output: '/* No matching variables found */', tokenCount: 0 }
}
const { tokens, modes } = buildTokens(variables, collections, allVars)
let output: string
if (format === 'tailwind') output = renderTailwindTheme(tokens, modes)
else if (format === 'json') output = renderJSON(tokens, modes)
else output = renderCSS(tokens, modes)
return { output, tokenCount: tokens.length, modeCount: modes.length }
}
})
interface ComponentInfo {
id: string
name: string
type: string
width: number
height: number
variants: string[]
instanceCount: number
propCandidates: string[]
usedOnScreens: string[]
}
interface ScreenInfo {
id: string
name: string
width: number
height: number
componentRefs: { id: string; name: string; count: number }[]
topLevelSections: number
}
function collectInstanceCounts(
figma: FigmaAPI,
componentIds: Set<string>
): Map<string, number> {
const counts = new Map<string, number>()
const page = figma.currentPage
page.findAll((node) => {
if (node.type !== 'INSTANCE') return false
const raw = figma.graph.getNode(node.id)
if (!raw?.componentId) return false
if (componentIds.has(raw.componentId)) {
counts.set(raw.componentId, (counts.get(raw.componentId) ?? 0) + 1)
}
return false
})
return counts
}
function detectPropCandidates(node: SceneNode, graph: import('../scene-graph').SceneGraph): string[] {
const props: string[] = []
for (const childId of node.childIds) {
const child = graph.getNode(childId)
if (!child) continue
if (child.type === 'TEXT' && child.text) {
props.push(`text:${child.name}`)
}
if (child.fills.some((f) => f.visible && f.type === 'SOLID')) {
const hasBoundVar = Object.keys(child.boundVariables).length > 0
if (hasBoundVar) props.push(`color:${child.name}`)
}
if (child.type === 'FRAME' || child.type === 'GROUP') {
props.push(`slot:${child.name}`)
}
}
return props
}
function detectVariants(
componentNode: SceneNode,
graph: import('../scene-graph').SceneGraph
): string[] {
if (componentNode.type !== 'COMPONENT_SET') return []
const variants: string[] = []
for (const childId of componentNode.childIds) {
const child = graph.getNode(childId)
if (child?.type === 'COMPONENT') variants.push(child.name)
}
return variants
}
function buildScreenInfo(
figma: FigmaAPI,
frameNode: SceneNode,
componentIds: Set<string>
): ScreenInfo {
const refs = new Map<string, { name: string; count: number }>()
let topLevelSections = 0
const walk = (nodeId: string) => {
const node = figma.graph.getNode(nodeId)
if (!node) return
if (node.type === 'SECTION') topLevelSections++
if (node.type === 'INSTANCE' && node.componentId && componentIds.has(node.componentId)) {
const entry = refs.get(node.componentId)
if (entry) entry.count++
else {
const comp = figma.graph.getNode(node.componentId)
refs.set(node.componentId, { name: comp?.name ?? node.componentId, count: 1 })
}
}
for (const childId of node.childIds) walk(childId)
}
for (const childId of frameNode.childIds) walk(childId)
return {
id: frameNode.id,
name: frameNode.name,
width: frameNode.width,
height: frameNode.height,
componentRefs: [...refs.entries()].map(([id, { name, count }]) => ({ id, name, count })),
topLevelSections
}
}
export const designToComponentMap = defineTool({
name: 'design_to_component_map',
description:
'Analyze the document and return a structured component decomposition: components (with variants, props, instance counts), screens, and a dependency overview.',
params: {
page: {
type: 'string',
description: 'Page name to analyze (default: current page)'
}
},
execute: (figma, args) => {
if (args.page) {
const target = figma.root.children.find((p) => p.name === args.page)
if (target) figma.currentPage = target
else return { error: `Page "${args.page}" not found` }
}
const page = figma.currentPage
const components: ComponentInfo[] = []
const componentIds = new Set<string>()
page.findAll((node) => {
if (node.type !== 'COMPONENT' && node.type !== 'COMPONENT_SET') return false
const raw = figma.graph.getNode(node.id)
if (!raw) return false
componentIds.add(raw.id)
components.push({
id: raw.id,
name: raw.name,
type: raw.type,
width: raw.width,
height: raw.height,
variants: detectVariants(raw, figma.graph),
instanceCount: 0,
propCandidates: detectPropCandidates(raw, figma.graph),
usedOnScreens: []
})
return false
})
const instanceCounts = collectInstanceCounts(figma, componentIds)
for (const comp of components) {
comp.instanceCount = instanceCounts.get(comp.id) ?? 0
}
const screens: ScreenInfo[] = []
const topFrames = page.children.filter((child) => {
const raw = figma.graph.getNode(child.id)
if (!raw) return false
if (raw.type === 'COMPONENT' || raw.type === 'COMPONENT_SET') return false
if (raw.type === 'SECTION') return false
return raw.width >= 200 && raw.height >= 200
})
for (const frame of topFrames) {
const raw = figma.graph.getNode(frame.id)
if (!raw) continue
const screen = buildScreenInfo(figma, raw, componentIds)
screens.push(screen)
for (const ref of screen.componentRefs) {
const comp = components.find((c) => c.id === ref.id)
if (comp) comp.usedOnScreens.push(screen.name)
}
}
const sections: { id: string; name: string; childCount: number }[] = []
for (const child of page.children) {
const raw = figma.graph.getNode(child.id)
if (raw?.type === 'SECTION') {
sections.push({ id: raw.id, name: raw.name, childCount: raw.childIds.length })
}
}
return {
componentCount: components.length,
screenCount: screens.length,
components: components.sort((a, b) => b.instanceCount - a.instanceCount),
screens,
sections
}
}
})
let _codegenPrompt: string | null = null
export async function loadCodegenPrompt(): Promise<string> {
if (_codegenPrompt) return _codegenPrompt
const { readFile } = await import('node:fs/promises')
const { dirname, join } = await import('node:path')
const { fileURLToPath } = await import('node:url')
const __dirname = dirname(fileURLToPath(import.meta.url))
_codegenPrompt = await readFile(join(__dirname, 'prompts', 'codegen.md'), 'utf-8')
return _codegenPrompt
}
export const getCodegenPrompt = defineTool({
name: 'get_codegen_prompt',
description:
'Get the design-to-code generation guidelines. Call this before generating frontend code from a Figma design to understand the recommended workflow and patterns.',
params: {},
execute: async () => {
const prompt = await loadCodegenPrompt()
return { prompt }
}
})

View file

@ -0,0 +1,231 @@
# Design to Code
You convert Figma designs into production frontend code. You have full access to the design document through tools. Never guess — always read the actual design data.
## Workflow
### Step 1 — Survey
Understand the full picture before writing any code.
```
get_page_tree → document structure, all top-level frames
get_components → reusable components defined by the designer
list_variables → design tokens (colors, numbers, strings, booleans)
list_collections → variable collections and modes (light/dark, density, etc.)
analyze_colors → color palette, frequencies, which colors use variables
analyze_typography → font stacks, sizes, weights in use
analyze_spacing → gap and padding values, grid compliance
```
After this step you should know:
- How many screens/pages the design has
- What components exist
- What the token system looks like (or if there is none)
- The typographic scale
- The spacing system (4px grid? 8px grid? irregular?)
### Step 2 — Decompose
Identify the component architecture.
```
analyze_clusters → find repeated visual patterns that should be components
describe (per node) → semantic role, layout direction, visual properties, issues
get_jsx (per node) → structural JSX to understand nesting and layout
```
Build a component map:
- **Screens** — top-level frames that represent pages/views
- **Components** — COMPONENT/COMPONENT_SET nodes or repeated patterns from analyze_clusters
- **Primitives** — leaf elements (text, icons, dividers) that don't need their own component file
For each component determine:
- **Props** — what content varies between instances (text, color, icon, visibility)
- **Variants** — if the component has multiple states (default/hover/active, small/medium/large)
- **Slots** — where child content is injected
### Step 3 — Extract tokens
```
list_variables → all variables with values per mode
list_collections → collection structure and mode names
```
Map design variables to code tokens. The approach depends on the target stack:
#### Tailwind projects
Do NOT create CSS custom properties for font sizes, font weights, spacing, or border radius — Tailwind has its own system for these. Use Tailwind utility classes directly:
- Font sizes → `text-[13px]`, `text-sm`, `text-base`, etc.
- Font weights → `font-bold`, `font-medium`, `font-[600]`
- Spacing → `gap-3`, `p-4`, `px-5`, `py-[14px]`, or arbitrary `gap-[12px]`
- Border radius → `rounded-xl`, `rounded-[14px]`, `rounded-full`
Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like `--font-bold`, `--text-sm`, `--radius-lg`):
```css
:root {
--movie-bg: #0F0F1A;
--movie-surface: #1A1A2E;
--movie-accent: #7C3AED;
--movie-text: #FFFFFF;
--movie-text-dim: #FFFFFF80;
}
```
Reference in Tailwind classes: `bg-[var(--movie-bg)]`, `text-[var(--movie-text)]`
#### CSS Modules / plain CSS projects
Create CSS custom properties for all token categories (colors, spacing, typography, radius). Use a project-specific prefix to avoid collisions:
```css
:root {
--app-color-bg: #0F0F1A;
--app-space-sm: 4px;
--app-text-sm: 12px;
--app-radius-md: 8px;
}
```
#### No design variables in the file
If the design has no Figma variables, extract implicit tokens from `analyze_colors` and `analyze_typography` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else.
#### Multi-mode collections (light/dark)
- Generate token values for each mode
- Use CSS custom properties with class-based switching (`.dark { ... }`)
### Step 4 — Generate code
For each component, bottom-up (primitives first, then composites, then screens):
```
get_jsx id=<component_id> → read structure
describe id=<component_id> → understand semantic role
export_svg ids=[<icon_ids>] → extract vector assets
```
**Rules:**
- One component per file
- Component name comes from the Figma node name, converted to PascalCase
- Props interface reflects the variable content identified in Step 2
- Use design tokens from Step 3 for semantic colors
- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in `var()` indirection
- Match measurements exactly: font sizes, spacing, border radii, colors
- Use auto-layout data to determine flex direction, gap, padding, alignment
- Absolute positioning only when `layoutPositioning` is `ABSOLUTE` or layout mode is `NONE`
- If a node has `clipsContent: true`, use `overflow: hidden`
- Text nodes: preserve font family, size, weight, line height, letter spacing, alignment
- Images/illustrations: use `export_svg` for vectors, placeholder `<img>` for raster
**Interactive states:**
Figma designs rarely include hover/active/focus states unless the component has explicit variants for them. Always add sensible interactive feedback to clickable elements:
- **Buttons (primary):** `hover:brightness-110 active:brightness-90 transition-all`
- **Buttons (secondary/ghost):** `hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors`
- **Icon buttons:** `hover:bg-white/[0.15] active:scale-95 transition-all`
- **Cards/list items (if clickable):** `hover:bg-white/[0.04] transition-colors`
- **Links/text buttons:** `hover:underline` or `hover:opacity-80`
- **All interactive elements:** add `cursor-pointer` and `select-none`
- **Focus visible:** add `focus-visible:ring-2 focus-visible:ring-offset-2` with accent color for accessibility
If the design HAS explicit hover/active variants (COMPONENT_SET with state property), use those exact styles instead of defaults above.
### Step 5 — Verify
After generating code, verify against the design:
```
describe id=<root> → re-check structure matches
get_jsx id=<root> → compare JSX structure with generated component tree
```
Check:
- All text content from the design appears in the code
- All colors reference tokens or use correct hex/opacity values
- Spacing values match the design
- Component hierarchy matches the design's node tree
- No nodes were skipped or merged incorrectly
List any deviations with rationale.
## Target stack
The user specifies the target stack. Adapt code generation accordingly:
**React + Tailwind** — functional components, TypeScript, utility classes, `className`
**React + CSS Modules** — functional components, TypeScript, `.module.css` files, `styles.className`
**Vue 3 + Tailwind** — `<script setup lang="ts">`, `defineProps`, `<template>`, Tailwind utility classes
**Vue 3 + CSS** — `<script setup lang="ts">`, `defineProps`, `<template>`, scoped `<style>`
**Svelte + Tailwind** — `<script lang="ts">`, `$props()`, Tailwind utility classes
**HTML + CSS** — semantic HTML, BEM or utility classes, CSS custom properties
If the user hasn't specified a stack, ask before generating code.
## Component file structure
```
components/
Button.tsx (or .vue, .svelte)
Card.tsx
Header.tsx
...
tokens.css (semantic color tokens only, for Tailwind projects)
pages/
HomePage.tsx (or routes, views — depends on framework)
assets/
icon-arrow.svg
icon-check.svg
```
## Common patterns
**Auto-layout → Flexbox**
- `layoutMode: HORIZONTAL``flex-direction: row`
- `layoutMode: VERTICAL``flex-direction: column`
- `itemSpacing``gap`
- `paddingTop/Right/Bottom/Left``padding`
- `primaryAxisAlign: CENTER``justify-content: center`
- `counterAxisAlign: CENTER``align-items: center`
- `layoutWrap: WRAP``flex-wrap: wrap`
- `primaryAxisSizing: HUG` → no explicit size on primary axis (content-sized)
- `primaryAxisSizing: FILL``flex: 1` or `width: 100%` depending on context
- `counterAxisSizing: FILL``align-self: stretch` or explicit `width/height: 100%`
**Grid layout**
- `layoutMode: GRID``display: grid`
- `gridTemplateColumns``grid-template-columns`
- `gridTemplateRows``grid-template-rows`
- `gridColumnGap/gridRowGap``column-gap/row-gap`
**Sizing**
- `layoutGrow > 0``flex-grow: 1`
- `layoutAlignSelf: STRETCH` → cross-axis fill
- Fixed width/height only when sizing mode is `FIXED`
**Corner radius**
- `independentCorners: true` → per-corner border-radius
- `cornerRadius` → uniform border-radius
**Effects**
- `DROP_SHADOW``box-shadow`
- `INNER_SHADOW``box-shadow: inset ...`
- `LAYER_BLUR``filter: blur(...)`
- `BACKGROUND_BLUR``backdrop-filter: blur(...)`
**Text**
- `fontFamily``font-family`
- `fontSize``font-size`
- `fontWeight``font-weight`
- `lineHeight``line-height` (null = normal/auto)
- `letterSpacing``letter-spacing`
- `textAlignHorizontal``text-align`
- `textAutoResize: WIDTH_AND_HEIGHT` → no explicit dimensions
- `textAutoResize: HEIGHT` → fixed width, auto height
- `textAutoResize: NONE` → fixed width and height
- `textDecoration``text-decoration`
- `textCase``text-transform`

View file

@ -36,6 +36,7 @@ import {
diffCreate, diffShow, evalCode
} from './analyze'
import { describe } from './describe'
import { designToTokens, designToComponentMap, getCodegenPrompt } from './codegen'
export const ALL_TOOLS: ToolDef[] = [
// Read
@ -136,6 +137,10 @@ export const ALL_TOOLS: ToolDef[] = [
diffCreate,
diffShow,
describe,
// Codegen
designToTokens,
designToComponentMap,
getCodegenPrompt,
// Eval
evalCode
]

View file

@ -0,0 +1,236 @@
---
name: open-pencil
description: Design-to-code via OpenPencil MCP server. Opens .fig files, inspects design structure, extracts tokens, analyzes patterns, and generates frontend code (Vue/React/Svelte + Tailwind/CSS). Use when user asks to convert Figma designs to code, inspect .fig files, or extract design tokens.
---
# OpenPencil MCP — Design to Code
## Prerequisites
OpenPencil MCP server must be running:
```bash
cd /mnt/f/projects/openspec/dannote/open-pencil/packages/mcp
bun src/http.ts &
```
Health check: `curl http://127.0.0.1:3100/health`
## MCP Connection
HTTP transport at `http://127.0.0.1:3100/mcp`. Session-based — initialize once, reuse session for all calls.
```bash
# Initialize
curl -s -X POST http://127.0.0.1:3100/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-D /tmp/mcp_headers.txt \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"pi","version":"1.0"}}}'
# Extract session ID
SESSION=$(grep -i 'mcp-session-id' /tmp/mcp_headers.txt | tr -d '\r' | awk '{print $2}')
# Send initialized notification
curl -s -X POST http://127.0.0.1:3100/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
```
### Calling Tools
```bash
curl -s -X POST http://127.0.0.1:3100/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: $SESSION" \
-d '{"jsonrpc":"2.0","id":NNN,"method":"tools/call","params":{"name":"TOOL_NAME","arguments":{...}}}'
```
Response is SSE — parse with: `sed 's/^event: message$//' | sed 's/^data: //'`
Result text is in: `result.content[0].text` (JSON string — parse twice for structured data).
## Pipeline: Design → Code
Follow these steps in order. Never guess — always read actual design data.
### Step 1 — Open File & Survey
```
open_file path=<.fig file> → opens document, returns page list
get_page_tree → full node hierarchy
get_components → reusable Figma components
design_to_tokens format="css" → extract design variables
analyze_colors limit=20 → color palette with frequencies
analyze_typography → font stacks, sizes, weights
analyze_spacing grid=4 → gap/padding values, grid compliance
```
After this step you know: screen count, component inventory, token system, type scale, spacing grid.
### Step 2 — Decompose
```
analyze_clusters min_count=2 → repeated patterns → potential components
describe id=<node> → semantic role, layout, visual properties
get_jsx id=<node> → structural JSX (nesting, props, text content)
design_to_component_map → screens, components, sections overview
```
Build component map: screens (top-level frames), components (repeated patterns or COMPONENT nodes), primitives (leaf text/icon nodes).
### Step 3 — Extract Tokens
For **Tailwind** stacks — only semantic colors as CSS custom properties:
```css
:root {
--app-bg: #0F0F1A;
--app-surface: #1A1A2E;
--app-accent: #7C3AED;
--app-text: #FFFFFF;
--app-text-dim: rgba(255, 255, 255, 0.5);
}
```
Use Tailwind utilities directly for spacing (`gap-3`, `p-4`), font sizes (`text-[13px]`), weights (`font-bold`), radius (`rounded-xl`). Do NOT create CSS variables for these — they conflict with Tailwind v4 internals.
For **CSS Modules / plain CSS** — variables for all categories with a project prefix.
### Step 4 — Generate Code
Bottom-up: primitives → components → screens. One file per component.
For each node, call `get_jsx` and `describe` to read exact structure, then translate:
| Figma concept | Code |
|---|---|
| `layoutMode: HORIZONTAL` | `flex flex-row` |
| `layoutMode: VERTICAL` | `flex flex-col` |
| `itemSpacing` | `gap-N` |
| `paddingTop/Right/Bottom/Left` | `p-N` / `px-N py-N` / `pt-N pr-N ...` |
| `primaryAxisAlign: CENTER` | `justify-center` |
| `counterAxisAlign: CENTER` | `items-center` |
| `layoutGrow > 0` | `flex-1` or `grow` |
| `cornerRadius` | `rounded-N` |
| `clipsContent: true` | `overflow-hidden` |
| `opacity < 1` | `opacity-N` |
| `layoutMode: NONE` | `relative` parent + `absolute` children |
Interactive states (buttons, cards): always add `cursor-pointer`, `hover:`, `active:`, `transition-*` even if Figma has no hover variants.
### Step 5 — Verify
Re-check generated code against design: all text present, colors match, spacing correct, hierarchy preserved.
## Tool Reference (91 tools)
### File & Navigation
| Tool | Params | Description |
|---|---|---|
| `open_file` | `path` | Open .fig/.figjam file |
| `get_page_tree` | — | Node tree of current page |
| `get_node` | `id` | Detailed node properties |
| `find_nodes` | `name?, type?` | Find by name/type |
| `get_selection` | — | Currently selected nodes |
| `query` | `xpath` | XPath node query |
### Read & Inspect
| Tool | Params | Description |
|---|---|---|
| `get_jsx` | `id` | JSX representation of node tree |
| `diff_jsx` | `from, to` | Structural diff between nodes |
| `describe` | `id` | Semantic description: role, layout, issues |
### Analysis
| Tool | Params | Description |
|---|---|---|
| `analyze_colors` | `limit?` | Color palette with frequencies |
| `analyze_typography` | — | Font families, sizes, weights |
| `analyze_spacing` | `grid?` | Gaps, paddings, grid compliance |
| `analyze_clusters` | `min_count?` | Repeated visual patterns |
### Variables & Tokens
| Tool | Params | Description |
|---|---|---|
| `list_variables` | `collection?` | All variables with values per mode |
| `list_collections` | — | Variable collections and modes |
| `get_components` | — | Component definitions |
### Codegen Pipeline
| Tool | Params | Description |
|---|---|---|
| `design_to_tokens` | `format` (css/tailwind/json) | Extract variables as code tokens |
| `design_to_component_map` | — | Screens, components, sections overview |
| `get_codegen_prompt` | — | Full codegen guidelines (6.7KB) |
### Export
| Tool | Params | Description |
|---|---|---|
| `export_svg` | `ids` | Export nodes as SVG |
| `export_png` | `id, scale?` | Export node as PNG |
### Create & Modify
| Tool | Params | Description |
|---|---|---|
| `render_jsx` | `jsx, x?, y?` | Render JSX into scene graph |
| `set_fill` | `id, color` | Set node fill |
| `set_text` | `id, text` | Set text content |
| `create_frame` | `width, height, ...` | Create frame node |
(Full list: call `tools/list` on MCP endpoint)
## Example Session
```bash
# 1. Open file
open_file path="/path/to/design.fig"
# 2. Survey
get_page_tree
analyze_colors limit=20
analyze_typography
analyze_spacing grid=4
# 3. Inspect root frame
get_jsx id="0:171"
describe id="0:171"
# 4. Inspect sections
describe id="0:189" # Content section
get_jsx id="0:200" # Stats component
# 5. Find patterns
analyze_clusters min_count=2
# 6. Get codegen guidelines
get_codegen_prompt
# 7. Generate code based on collected data
# → Write Vue/React components with Tailwind classes
# → Create tokens.css with semantic color variables
# → Match every measurement from the design exactly
```
## Stack-Specific Notes
### Vue 3 + Tailwind
- `<script setup lang="ts">`, `defineProps`, `<template>`
- Tailwind utility classes, no scoped styles needed
- CSS custom properties only for semantic colors in a global `tokens.css`
- Reference: `bg-[var(--app-accent)]`, `text-[var(--app-text)]`
### React + Tailwind
- Functional components, TypeScript, `className`
- Same token strategy as Vue
### Vue 3 + CSS
- `<script setup lang="ts">`, scoped `<style>`
- CSS custom properties for all token categories
### HTML + CSS
- Semantic HTML, BEM classes
- Full CSS custom property system