Add MCP codegen pipeline: tools, prompt, and plan
This commit is contained in:
parent
9f98833495
commit
31dbe9690c
244
docs/design-to-code-overview.md
Normal file
244
docs/design-to-code-overview.md
Normal 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
97
docs/mcp-codegen-plan.md
Normal 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
|
||||
└─────────────────────┘
|
||||
```
|
||||
446
packages/core/src/tools/codegen.ts
Normal file
446
packages/core/src/tools/codegen.ts
Normal 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 }
|
||||
}
|
||||
})
|
||||
231
packages/core/src/tools/prompts/codegen.md
Normal file
231
packages/core/src/tools/prompts/codegen.md
Normal 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`
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
236
packages/mcp/skills/open-pencil/SKILL.md
Normal file
236
packages/mcp/skills/open-pencil/SKILL.md
Normal 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
|
||||
Loading…
Reference in a new issue