Initial commit
This commit is contained in:
690
commands/figma-import.md
Normal file
690
commands/figma-import.md
Normal file
@@ -0,0 +1,690 @@
|
||||
# /specweave-figma:import
|
||||
|
||||
Import Figma designs into your project using Figma REST API or MCP server integration.
|
||||
|
||||
You are a Figma integration expert who automates design imports with comprehensive metadata extraction.
|
||||
|
||||
## Your Task
|
||||
|
||||
Import Figma designs (files, frames, components, styles) into your project with full metadata, assets, and specifications.
|
||||
|
||||
### 1. Figma API Overview
|
||||
|
||||
**REST API Capabilities**:
|
||||
- Fetch files, frames, components, and styles
|
||||
- Export images (PNG, JPG, SVG, PDF)
|
||||
- Access design tokens (colors, typography, spacing)
|
||||
- Read component metadata and descriptions
|
||||
- Retrieve version history
|
||||
|
||||
**API Endpoints**:
|
||||
```
|
||||
GET /v1/files/:file_key # Get file metadata
|
||||
GET /v1/files/:file_key/nodes # Get specific nodes
|
||||
GET /v1/images/:file_key # Export images
|
||||
GET /v1/files/:file_key/components # Get components
|
||||
GET /v1/files/:file_key/styles # Get styles
|
||||
GET /v1/teams/:team_id/projects # List projects
|
||||
GET /v1/files/:file_key/versions # Version history
|
||||
```
|
||||
|
||||
**Authentication**:
|
||||
```bash
|
||||
# Personal Access Token (recommended for server-side)
|
||||
curl -H "X-Figma-Token: YOUR_TOKEN" https://api.figma.com/v1/files/:file_key
|
||||
|
||||
# OAuth 2.0 (recommended for user-facing apps)
|
||||
# Authorization: Bearer YOUR_OAUTH_TOKEN
|
||||
```
|
||||
|
||||
### 2. Setup Configuration
|
||||
|
||||
**Environment Variables (.env)**:
|
||||
```bash
|
||||
# Required
|
||||
FIGMA_ACCESS_TOKEN=figd_XXXXXXXXXXXXXXXXXXXX
|
||||
|
||||
# Optional (MCP server)
|
||||
FIGMA_MCP_ENABLED=true
|
||||
FIGMA_MCP_SERVER_PATH=/path/to/figma-mcp-server
|
||||
```
|
||||
|
||||
**Configuration File (figma.config.json)**:
|
||||
```json
|
||||
{
|
||||
"fileKey": "ABC123XYZ456",
|
||||
"fileUrl": "https://www.figma.com/file/ABC123XYZ456/ProjectName",
|
||||
"importSettings": {
|
||||
"components": true,
|
||||
"styles": true,
|
||||
"assets": true,
|
||||
"exportFormats": ["svg", "png@2x"],
|
||||
"skipHidden": true,
|
||||
"includeMetadata": true
|
||||
},
|
||||
"exportPaths": {
|
||||
"components": "./src/components/figma",
|
||||
"assets": "./public/assets/figma",
|
||||
"tokens": "./src/design-tokens",
|
||||
"metadata": "./.figma/metadata.json"
|
||||
},
|
||||
"naming": {
|
||||
"componentPrefix": "Figma",
|
||||
"useKebabCase": true,
|
||||
"preserveFigmaNames": false
|
||||
},
|
||||
"mcp": {
|
||||
"enabled": false,
|
||||
"serverPath": null,
|
||||
"cacheDir": "./.figma/cache"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Figma REST API Integration
|
||||
|
||||
**TypeScript/JavaScript Implementation**:
|
||||
|
||||
```typescript
|
||||
import axios from 'axios';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface FigmaConfig {
|
||||
accessToken: string;
|
||||
fileKey: string;
|
||||
exportFormats?: string[];
|
||||
}
|
||||
|
||||
class FigmaImporter {
|
||||
private baseUrl = 'https://api.figma.com/v1';
|
||||
private headers: Record<string, string>;
|
||||
private fileKey: string;
|
||||
|
||||
constructor(config: FigmaConfig) {
|
||||
this.headers = {
|
||||
'X-Figma-Token': config.accessToken,
|
||||
};
|
||||
this.fileKey = config.fileKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch file metadata and structure
|
||||
*/
|
||||
async fetchFile() {
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/files/${this.fileKey}`,
|
||||
{ headers: this.headers }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch specific nodes by ID
|
||||
*/
|
||||
async fetchNodes(nodeIds: string[]) {
|
||||
const ids = nodeIds.join(',');
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/files/${this.fileKey}/nodes?ids=${ids}`,
|
||||
{ headers: this.headers }
|
||||
);
|
||||
|
||||
return response.data.nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export images for nodes
|
||||
*/
|
||||
async exportImages(
|
||||
nodeIds: string[],
|
||||
options: {
|
||||
format?: 'png' | 'jpg' | 'svg' | 'pdf';
|
||||
scale?: number;
|
||||
useAbsoluteBounds?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const { format = 'png', scale = 2, useAbsoluteBounds = false } = options;
|
||||
const ids = nodeIds.join(',');
|
||||
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/images/${this.fileKey}?ids=${ids}&format=${format}&scale=${scale}&use_absolute_bounds=${useAbsoluteBounds}`,
|
||||
{ headers: this.headers }
|
||||
);
|
||||
|
||||
return response.data.images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all components in file
|
||||
*/
|
||||
async fetchComponents() {
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/files/${this.fileKey}/components`,
|
||||
{ headers: this.headers }
|
||||
);
|
||||
|
||||
return response.data.meta.components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all styles (colors, text, effects, grids)
|
||||
*/
|
||||
async fetchStyles() {
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/files/${this.fileKey}/styles`,
|
||||
{ headers: this.headers }
|
||||
);
|
||||
|
||||
return response.data.meta.styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download image from URL
|
||||
*/
|
||||
async downloadImage(imageUrl: string, outputPath: string) {
|
||||
const response = await axios.get(imageUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, response.data);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse Figma node tree recursively
|
||||
*/
|
||||
traverseNodes(node: any, callback: (node: any) => void) {
|
||||
callback(node);
|
||||
|
||||
if (node.children) {
|
||||
node.children.forEach((child: any) => {
|
||||
this.traverseNodes(child, callback);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract design tokens from file
|
||||
*/
|
||||
async extractDesignTokens() {
|
||||
const file = await this.fetchFile();
|
||||
const styles = await this.fetchStyles();
|
||||
|
||||
const tokens = {
|
||||
colors: {},
|
||||
typography: {},
|
||||
spacing: {},
|
||||
effects: {},
|
||||
};
|
||||
|
||||
// Extract color tokens
|
||||
styles.forEach((style: any) => {
|
||||
if (style.style_type === 'FILL') {
|
||||
const node = this.findNodeById(file.document, style.node_id);
|
||||
if (node?.fills?.[0]) {
|
||||
const color = node.fills[0];
|
||||
if (color.type === 'SOLID') {
|
||||
tokens.colors[style.name] = this.rgbaToHex(color.color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text styles
|
||||
if (style.style_type === 'TEXT') {
|
||||
const node = this.findNodeById(file.document, style.node_id);
|
||||
if (node?.style) {
|
||||
tokens.typography[style.name] = {
|
||||
fontFamily: node.style.fontFamily,
|
||||
fontSize: node.style.fontSize,
|
||||
fontWeight: node.style.fontWeight,
|
||||
lineHeight: node.style.lineHeightPx,
|
||||
letterSpacing: node.style.letterSpacing,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract effect styles (shadows, blurs)
|
||||
if (style.style_type === 'EFFECT') {
|
||||
const node = this.findNodeById(file.document, style.node_id);
|
||||
if (node?.effects) {
|
||||
tokens.effects[style.name] = node.effects;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find node by ID in tree
|
||||
*/
|
||||
private findNodeById(node: any, id: string): any {
|
||||
if (node.id === id) return node;
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = this.findNodeById(child, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGBA to HEX
|
||||
*/
|
||||
private rgbaToHex(color: { r: number; g: number; b: number; a?: number }): string {
|
||||
const r = Math.round(color.r * 255);
|
||||
const g = Math.round(color.g * 255);
|
||||
const b = Math.round(color.b * 255);
|
||||
|
||||
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
async function importFigmaDesigns() {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey: 'ABC123XYZ456',
|
||||
});
|
||||
|
||||
// Fetch file structure
|
||||
const file = await importer.fetchFile();
|
||||
console.log('File:', file.name);
|
||||
|
||||
// Fetch components
|
||||
const components = await importer.fetchComponents();
|
||||
console.log(`Found ${components.length} components`);
|
||||
|
||||
// Export component images
|
||||
const componentIds = components.map((c: any) => c.node_id);
|
||||
const images = await importer.exportImages(componentIds, {
|
||||
format: 'svg',
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// Download images
|
||||
for (const [nodeId, imageUrl] of Object.entries(images)) {
|
||||
const component = components.find((c: any) => c.node_id === nodeId);
|
||||
const outputPath = `./assets/${component.name}.svg`;
|
||||
await importer.downloadImage(imageUrl as string, outputPath);
|
||||
console.log(`Exported: ${outputPath}`);
|
||||
}
|
||||
|
||||
// Extract design tokens
|
||||
const tokens = await importer.extractDesignTokens();
|
||||
await fs.writeFile(
|
||||
'./src/design-tokens/figma-tokens.json',
|
||||
JSON.stringify(tokens, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
importFigmaDesigns().catch(console.error);
|
||||
```
|
||||
|
||||
### 4. MCP Server Integration
|
||||
|
||||
**MCP Server for Figma** (Recommended for Claude Code):
|
||||
|
||||
MCP (Model Context Protocol) servers provide structured access to Figma via Claude Code.
|
||||
|
||||
**Available MCP Servers**:
|
||||
1. **@modelcontextprotocol/server-figma** (Official)
|
||||
2. **figma-mcp-server** (Community)
|
||||
3. **claude-figma-bridge** (Custom)
|
||||
|
||||
**Setup MCP Server**:
|
||||
|
||||
```bash
|
||||
# Install MCP server
|
||||
npm install -g @modelcontextprotocol/server-figma
|
||||
|
||||
# Configure in Claude Code settings
|
||||
# ~/.claude/config.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"figma": {
|
||||
"command": "mcp-server-figma",
|
||||
"args": [],
|
||||
"env": {
|
||||
"FIGMA_ACCESS_TOKEN": "figd_XXXXXXXXXXXXXXXXXXXX"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Using MCP in Claude Code**:
|
||||
|
||||
Once MCP server is configured, Claude Code can directly access Figma:
|
||||
|
||||
```typescript
|
||||
// Claude Code will use MCP tools automatically
|
||||
// Example: mcp__figma__getFile, mcp__figma__exportImages
|
||||
|
||||
// In your prompt:
|
||||
// "Import all components from Figma file ABC123XYZ456 and export as SVG"
|
||||
|
||||
// Claude Code will invoke:
|
||||
// - mcp__figma__getFile({ fileKey: "ABC123XYZ456" })
|
||||
// - mcp__figma__exportImages({ nodeIds: [...], format: "svg" })
|
||||
```
|
||||
|
||||
**MCP Server Benefits**:
|
||||
- No need to manage API tokens in code
|
||||
- Automatic rate limiting
|
||||
- Built-in caching
|
||||
- Structured tool interface
|
||||
- Better error handling
|
||||
|
||||
### 5. Import Workflow
|
||||
|
||||
**Step 1: Analyze Figma File**
|
||||
```typescript
|
||||
// Discover structure
|
||||
const file = await importer.fetchFile();
|
||||
|
||||
// Find components, frames, pages
|
||||
const components = [];
|
||||
const frames = [];
|
||||
|
||||
importer.traverseNodes(file.document, (node) => {
|
||||
if (node.type === 'COMPONENT') {
|
||||
components.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
description: node.description,
|
||||
componentPropertyDefinitions: node.componentPropertyDefinitions,
|
||||
});
|
||||
}
|
||||
|
||||
if (node.type === 'FRAME') {
|
||||
frames.push({
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
width: node.absoluteBoundingBox.width,
|
||||
height: node.absoluteBoundingBox.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Components: ${components.length}, Frames: ${frames.length}`);
|
||||
```
|
||||
|
||||
**Step 2: Export Assets**
|
||||
```typescript
|
||||
// Export all components as SVG
|
||||
const componentIds = components.map(c => c.id);
|
||||
const svgImages = await importer.exportImages(componentIds, {
|
||||
format: 'svg',
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// Export all frames as PNG@2x
|
||||
const frameIds = frames.map(f => f.id);
|
||||
const pngImages = await importer.exportImages(frameIds, {
|
||||
format: 'png',
|
||||
scale: 2,
|
||||
});
|
||||
```
|
||||
|
||||
**Step 3: Save Metadata**
|
||||
```typescript
|
||||
const metadata = {
|
||||
fileKey: importer.fileKey,
|
||||
fileName: file.name,
|
||||
version: file.version,
|
||||
lastModified: file.lastModified,
|
||||
components: components.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
exportedAs: `./assets/components/${c.name}.svg`,
|
||||
})),
|
||||
frames: frames.map(f => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
dimensions: { width: f.width, height: f.height },
|
||||
exportedAs: `./assets/frames/${f.name}@2x.png`,
|
||||
})),
|
||||
importedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
'./.figma/metadata.json',
|
||||
JSON.stringify(metadata, null, 2)
|
||||
);
|
||||
```
|
||||
|
||||
### 6. CLI Command
|
||||
|
||||
**NPM Script (package.json)**:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"figma:import": "tsx scripts/figma-import.ts",
|
||||
"figma:import:watch": "tsx scripts/figma-import.ts --watch",
|
||||
"figma:sync": "tsx scripts/figma-sync.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI with Arguments**:
|
||||
```typescript
|
||||
// scripts/figma-import.ts
|
||||
import yargs from 'yargs';
|
||||
|
||||
const argv = yargs(process.argv.slice(2))
|
||||
.option('file-key', {
|
||||
type: 'string',
|
||||
description: 'Figma file key',
|
||||
required: true,
|
||||
})
|
||||
.option('components', {
|
||||
type: 'boolean',
|
||||
description: 'Import components',
|
||||
default: true,
|
||||
})
|
||||
.option('assets', {
|
||||
type: 'boolean',
|
||||
description: 'Export assets',
|
||||
default: true,
|
||||
})
|
||||
.option('tokens', {
|
||||
type: 'boolean',
|
||||
description: 'Extract design tokens',
|
||||
default: true,
|
||||
})
|
||||
.option('format', {
|
||||
type: 'string',
|
||||
description: 'Export format (svg, png, jpg)',
|
||||
default: 'svg',
|
||||
})
|
||||
.parseSync();
|
||||
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey: argv.fileKey,
|
||||
});
|
||||
|
||||
if (argv.components) {
|
||||
const components = await importer.fetchComponents();
|
||||
console.log(`Importing ${components.length} components...`);
|
||||
}
|
||||
|
||||
if (argv.assets) {
|
||||
// Export logic
|
||||
}
|
||||
|
||||
if (argv.tokens) {
|
||||
const tokens = await importer.extractDesignTokens();
|
||||
// Save tokens
|
||||
}
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
# Import everything
|
||||
npm run figma:import -- --file-key ABC123XYZ456
|
||||
|
||||
# Import only components
|
||||
npm run figma:import -- --file-key ABC123XYZ456 --no-assets --no-tokens
|
||||
|
||||
# Export as PNG
|
||||
npm run figma:import -- --file-key ABC123XYZ456 --format png
|
||||
```
|
||||
|
||||
### 7. Rate Limiting and Caching
|
||||
|
||||
**Rate Limits** (Figma API):
|
||||
- 30 requests per minute per IP
|
||||
- 1000 requests per hour per token
|
||||
|
||||
**Implement Caching**:
|
||||
```typescript
|
||||
import NodeCache from 'node-cache';
|
||||
|
||||
class CachedFigmaImporter extends FigmaImporter {
|
||||
private cache: NodeCache;
|
||||
|
||||
constructor(config: FigmaConfig) {
|
||||
super(config);
|
||||
this.cache = new NodeCache({ stdTTL: 3600 }); // 1 hour TTL
|
||||
}
|
||||
|
||||
async fetchFile() {
|
||||
const cacheKey = `file:${this.fileKey}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
console.log('Using cached file data');
|
||||
return cached;
|
||||
}
|
||||
|
||||
const file = await super.fetchFile();
|
||||
this.cache.set(cacheKey, file);
|
||||
return file;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rate Limiting**:
|
||||
```typescript
|
||||
import Bottleneck from 'bottleneck';
|
||||
|
||||
class RateLimitedFigmaImporter extends FigmaImporter {
|
||||
private limiter: Bottleneck;
|
||||
|
||||
constructor(config: FigmaConfig) {
|
||||
super(config);
|
||||
|
||||
// 30 requests per minute
|
||||
this.limiter = new Bottleneck({
|
||||
minTime: 2000, // 2s between requests
|
||||
maxConcurrent: 1,
|
||||
});
|
||||
}
|
||||
|
||||
async fetchFile() {
|
||||
return this.limiter.schedule(() => super.fetchFile());
|
||||
}
|
||||
|
||||
async exportImages(...args: any[]) {
|
||||
return this.limiter.schedule(() => super.exportImages(...args));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Error Handling
|
||||
|
||||
**Common API Errors**:
|
||||
- 401 Unauthorized: Invalid access token
|
||||
- 403 Forbidden: No permission to access file
|
||||
- 404 Not Found: File or node doesn't exist
|
||||
- 429 Too Many Requests: Rate limit exceeded
|
||||
- 500 Server Error: Figma API issue
|
||||
|
||||
**Error Handling Pattern**:
|
||||
```typescript
|
||||
async function importWithRetry(fileKey: string, retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
return await importer.fetchFile();
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
|
||||
if (status === 401) {
|
||||
throw new Error('Invalid Figma access token');
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
throw new Error('No permission to access this file');
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
const retryAfter = error.response?.headers['retry-after'] || 60;
|
||||
console.log(`Rate limited. Retrying after ${retryAfter}s...`);
|
||||
await sleep(retryAfter * 1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (status === 500 && i < retries - 1) {
|
||||
console.log(`Server error. Retrying (${i + 1}/${retries})...`);
|
||||
await sleep(2000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Max retries exceeded');
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Ask about Figma file URL and access requirements
|
||||
2. Extract file key from URL
|
||||
3. Verify Figma access token (from .env or prompt)
|
||||
4. Fetch file metadata and analyze structure
|
||||
5. Ask about import preferences (components, assets, tokens)
|
||||
6. Set up directory structure for exports
|
||||
7. Export assets with specified formats and scales
|
||||
8. Extract design tokens and metadata
|
||||
9. Save all data to configured paths
|
||||
10. Generate import report with statistics
|
||||
|
||||
## When to Use
|
||||
|
||||
- Starting new projects with Figma designs
|
||||
- Syncing design systems with code
|
||||
- Automating asset exports from Figma
|
||||
- Extracting design tokens for theme configuration
|
||||
- Migrating from manual Figma exports to automated workflow
|
||||
- Setting up CI/CD for design-to-code pipeline
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Security**: Store access tokens in .env (never commit)
|
||||
2. **Caching**: Cache file data to reduce API calls
|
||||
3. **Rate Limiting**: Respect Figma API limits (30 req/min)
|
||||
4. **Versioning**: Track Figma file versions in metadata
|
||||
5. **Organization**: Structure exports by component type
|
||||
6. **Documentation**: Include component descriptions from Figma
|
||||
7. **Automation**: Use watch mode for continuous sync
|
||||
8. **Testing**: Validate exports before committing
|
||||
|
||||
Import Figma designs with production-ready automation!
|
||||
834
commands/figma-to-react.md
Normal file
834
commands/figma-to-react.md
Normal file
@@ -0,0 +1,834 @@
|
||||
# /specweave-figma:to-react
|
||||
|
||||
Convert Figma components to production-ready React components with TypeScript, styled-components, and responsive design.
|
||||
|
||||
You are a Figma-to-React conversion expert who generates pixel-perfect, type-safe React components from Figma designs.
|
||||
|
||||
## Your Task
|
||||
|
||||
Transform Figma components into React components with proper TypeScript types, styling, accessibility, and responsive behavior.
|
||||
|
||||
### 1. Conversion Architecture
|
||||
|
||||
**Design-to-Code Pipeline**:
|
||||
```
|
||||
Figma Component → Analyze Structure → Extract Props → Generate TSX → Apply Styles → Add Interactivity
|
||||
```
|
||||
|
||||
**Supported Patterns**:
|
||||
- Functional components with hooks
|
||||
- TypeScript interfaces for props
|
||||
- Styled-components or CSS modules
|
||||
- Responsive design (mobile-first)
|
||||
- Accessibility attributes (ARIA)
|
||||
- Component variants (Figma properties)
|
||||
- Auto-layout → Flexbox/Grid
|
||||
|
||||
### 2. Component Analysis
|
||||
|
||||
**Figma Node Structure**:
|
||||
```typescript
|
||||
interface FigmaNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'COMPONENT' | 'FRAME' | 'TEXT' | 'RECTANGLE' | 'VECTOR';
|
||||
children?: FigmaNode[];
|
||||
|
||||
// Layout
|
||||
absoluteBoundingBox: { x: number; y: number; width: number; height: number };
|
||||
layoutMode?: 'HORIZONTAL' | 'VERTICAL' | 'NONE';
|
||||
layoutAlign?: 'MIN' | 'CENTER' | 'MAX' | 'STRETCH';
|
||||
primaryAxisSizingMode?: 'FIXED' | 'AUTO';
|
||||
counterAxisSizingMode?: 'FIXED' | 'AUTO';
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingTop?: number;
|
||||
paddingBottom?: number;
|
||||
itemSpacing?: number;
|
||||
|
||||
// Styling
|
||||
fills?: Fill[];
|
||||
strokes?: Stroke[];
|
||||
effects?: Effect[];
|
||||
cornerRadius?: number;
|
||||
|
||||
// Text
|
||||
characters?: string;
|
||||
style?: TextStyle;
|
||||
|
||||
// Component properties (variants)
|
||||
componentPropertyDefinitions?: Record<string, ComponentProperty>;
|
||||
}
|
||||
```
|
||||
|
||||
**Extract Component Metadata**:
|
||||
```typescript
|
||||
function analyzeComponent(node: FigmaNode) {
|
||||
return {
|
||||
name: node.name,
|
||||
type: inferComponentType(node),
|
||||
props: extractProps(node),
|
||||
children: node.children?.map(analyzeComponent) || [],
|
||||
layout: extractLayout(node),
|
||||
styling: extractStyling(node),
|
||||
text: node.type === 'TEXT' ? node.characters : null,
|
||||
variants: node.componentPropertyDefinitions || {},
|
||||
};
|
||||
}
|
||||
|
||||
function inferComponentType(node: FigmaNode): string {
|
||||
// Button detection
|
||||
if (node.name.toLowerCase().includes('button')) return 'Button';
|
||||
|
||||
// Input detection
|
||||
if (node.name.toLowerCase().includes('input')) return 'Input';
|
||||
|
||||
// Card detection
|
||||
if (node.name.toLowerCase().includes('card')) return 'Card';
|
||||
|
||||
// Icon detection
|
||||
if (node.type === 'VECTOR') return 'Icon';
|
||||
|
||||
// Text detection
|
||||
if (node.type === 'TEXT') return 'Text';
|
||||
|
||||
// Generic container
|
||||
return 'Container';
|
||||
}
|
||||
```
|
||||
|
||||
### 3. React Component Generation
|
||||
|
||||
**TypeScript Component Template**:
|
||||
|
||||
```typescript
|
||||
import { FC } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
// Generated interfaces from Figma component properties
|
||||
interface ${ComponentName}Props {
|
||||
variant?: 'primary' | 'secondary' | 'tertiary';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ${ComponentName}: FC<${ComponentName}Props> = ({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<StyledContainer
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div<${ComponentName}Props>`
|
||||
/* Generated styles from Figma */
|
||||
`;
|
||||
|
||||
export default ${ComponentName};
|
||||
```
|
||||
|
||||
**Complete Example - Button Component**:
|
||||
|
||||
```typescript
|
||||
import { FC, ButtonHTMLAttributes } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Button: FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<StyledButton
|
||||
variant={variant}
|
||||
size={size}
|
||||
fullWidth={fullWidth}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{leftIcon && <IconWrapper>{leftIcon}</IconWrapper>}
|
||||
{loading ? <Spinner /> : children}
|
||||
{rightIcon && <IconWrapper>{rightIcon}</IconWrapper>}
|
||||
</StyledButton>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledButton = styled.button<ButtonProps>`
|
||||
/* Base styles */
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
/* Full width */
|
||||
width: ${({ fullWidth }) => fullWidth ? '100%' : 'auto'};
|
||||
|
||||
/* Size variants */
|
||||
${({ size }) => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return `
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
`;
|
||||
case 'lg':
|
||||
return `
|
||||
padding: 16px 32px;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
`;
|
||||
default: // md
|
||||
return `
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
/* Color variants */
|
||||
${({ variant }) => {
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `
|
||||
background: #0066FF;
|
||||
color: #FFFFFF;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #0052CC;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: #003D99;
|
||||
}
|
||||
`;
|
||||
case 'secondary':
|
||||
return `
|
||||
background: #F0F0F0;
|
||||
color: #333333;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: #E0E0E0;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: #D0D0D0;
|
||||
}
|
||||
`;
|
||||
case 'outline':
|
||||
return `
|
||||
background: transparent;
|
||||
color: #0066FF;
|
||||
border: 2px solid #0066FF;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: rgba(0, 102, 255, 0.2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
/* Disabled state */
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Focus state (accessibility) */
|
||||
&:focus-visible {
|
||||
outline: 2px solid #0066FF;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Spinner = styled.div`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
|
||||
export default Button;
|
||||
```
|
||||
|
||||
### 4. Style Conversion
|
||||
|
||||
**Figma → CSS Mapping**:
|
||||
|
||||
```typescript
|
||||
function convertFigmaStylesToCSS(node: FigmaNode): string {
|
||||
const styles: string[] = [];
|
||||
|
||||
// Layout (Auto Layout → Flexbox)
|
||||
if (node.layoutMode) {
|
||||
styles.push('display: flex;');
|
||||
styles.push(`flex-direction: ${node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'};`);
|
||||
|
||||
if (node.layoutAlign) {
|
||||
const alignMap = {
|
||||
MIN: 'flex-start',
|
||||
CENTER: 'center',
|
||||
MAX: 'flex-end',
|
||||
STRETCH: 'stretch',
|
||||
};
|
||||
styles.push(`align-items: ${alignMap[node.layoutAlign]};`);
|
||||
}
|
||||
|
||||
if (node.itemSpacing) {
|
||||
styles.push(`gap: ${node.itemSpacing}px;`);
|
||||
}
|
||||
}
|
||||
|
||||
// Padding
|
||||
if (node.paddingLeft || node.paddingRight || node.paddingTop || node.paddingBottom) {
|
||||
const padding = [
|
||||
node.paddingTop || 0,
|
||||
node.paddingRight || 0,
|
||||
node.paddingBottom || 0,
|
||||
node.paddingLeft || 0,
|
||||
].join('px ');
|
||||
styles.push(`padding: ${padding}px;`);
|
||||
}
|
||||
|
||||
// Size
|
||||
const box = node.absoluteBoundingBox;
|
||||
if (node.primaryAxisSizingMode === 'FIXED') {
|
||||
const dim = node.layoutMode === 'HORIZONTAL' ? 'width' : 'height';
|
||||
styles.push(`${dim}: ${box.width}px;`);
|
||||
}
|
||||
|
||||
// Background (fills)
|
||||
if (node.fills && node.fills.length > 0) {
|
||||
const fill = node.fills[0];
|
||||
if (fill.type === 'SOLID') {
|
||||
const color = rgbaToCSS(fill.color, fill.opacity);
|
||||
styles.push(`background: ${color};`);
|
||||
} else if (fill.type === 'GRADIENT_LINEAR') {
|
||||
const gradient = convertGradient(fill);
|
||||
styles.push(`background: ${gradient};`);
|
||||
}
|
||||
}
|
||||
|
||||
// Border (strokes)
|
||||
if (node.strokes && node.strokes.length > 0) {
|
||||
const stroke = node.strokes[0];
|
||||
const color = rgbaToCSS(stroke.color, stroke.opacity);
|
||||
const width = node.strokeWeight || 1;
|
||||
styles.push(`border: ${width}px solid ${color};`);
|
||||
}
|
||||
|
||||
// Border radius
|
||||
if (node.cornerRadius) {
|
||||
styles.push(`border-radius: ${node.cornerRadius}px;`);
|
||||
}
|
||||
|
||||
// Shadows (effects)
|
||||
if (node.effects && node.effects.length > 0) {
|
||||
const shadows = node.effects
|
||||
.filter((e: any) => e.type === 'DROP_SHADOW')
|
||||
.map((e: any) => {
|
||||
const color = rgbaToCSS(e.color, e.color.a);
|
||||
return `${e.offset.x}px ${e.offset.y}px ${e.radius}px ${color}`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
if (shadows) {
|
||||
styles.push(`box-shadow: ${shadows};`);
|
||||
}
|
||||
}
|
||||
|
||||
// Typography (text styles)
|
||||
if (node.style) {
|
||||
styles.push(`font-family: '${node.style.fontFamily}', sans-serif;`);
|
||||
styles.push(`font-size: ${node.style.fontSize}px;`);
|
||||
styles.push(`font-weight: ${node.style.fontWeight};`);
|
||||
styles.push(`line-height: ${node.style.lineHeightPx}px;`);
|
||||
|
||||
if (node.style.letterSpacing) {
|
||||
styles.push(`letter-spacing: ${node.style.letterSpacing}px;`);
|
||||
}
|
||||
|
||||
if (node.style.textAlignHorizontal) {
|
||||
const alignMap = { LEFT: 'left', CENTER: 'center', RIGHT: 'right', JUSTIFIED: 'justify' };
|
||||
styles.push(`text-align: ${alignMap[node.style.textAlignHorizontal]};`);
|
||||
}
|
||||
}
|
||||
|
||||
return styles.join('\n ');
|
||||
}
|
||||
|
||||
function rgbaToCSS(color: { r: number; g: number; b: number }, opacity = 1): string {
|
||||
const r = Math.round(color.r * 255);
|
||||
const g = Math.round(color.g * 255);
|
||||
const b = Math.round(color.b * 255);
|
||||
|
||||
return opacity === 1
|
||||
? `rgb(${r}, ${g}, ${b})`
|
||||
: `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
||||
}
|
||||
|
||||
function convertGradient(fill: any): string {
|
||||
const stops = fill.gradientStops
|
||||
.map((stop: any) => {
|
||||
const color = rgbaToCSS(stop.color, stop.color.a);
|
||||
return `${color} ${Math.round(stop.position * 100)}%`;
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
const angle = Math.atan2(
|
||||
fill.gradientHandlePositions[1].y - fill.gradientHandlePositions[0].y,
|
||||
fill.gradientHandlePositions[1].x - fill.gradientHandlePositions[0].x
|
||||
) * (180 / Math.PI);
|
||||
|
||||
return `linear-gradient(${angle}deg, ${stops})`;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Responsive Design
|
||||
|
||||
**Generate Media Queries from Figma Breakpoints**:
|
||||
|
||||
```typescript
|
||||
const breakpoints = {
|
||||
mobile: 375,
|
||||
tablet: 768,
|
||||
desktop: 1440,
|
||||
};
|
||||
|
||||
const StyledCard = styled.div`
|
||||
/* Mobile-first base styles */
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: ${breakpoints.tablet}px) {
|
||||
padding: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Desktop */
|
||||
@media (min-width: ${breakpoints.desktop}px) {
|
||||
padding: 32px;
|
||||
font-size: 18px;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
**Responsive Container**:
|
||||
```typescript
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
padding: 0 64px;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### 6. Component Variants (Figma Properties)
|
||||
|
||||
**Figma Component Property → React Props**:
|
||||
|
||||
```typescript
|
||||
// Figma component with variants:
|
||||
// - State: Default, Hover, Active, Disabled
|
||||
// - Size: Small, Medium, Large
|
||||
// - Icon: None, Left, Right
|
||||
|
||||
interface ChipProps {
|
||||
state?: 'default' | 'hover' | 'active' | 'disabled';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
iconPosition?: 'none' | 'left' | 'right';
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Chip: FC<ChipProps> = ({
|
||||
state = 'default',
|
||||
size = 'md',
|
||||
iconPosition = 'none',
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<StyledChip
|
||||
state={state}
|
||||
size={size}
|
||||
onClick={state !== 'disabled' ? onClick : undefined}
|
||||
>
|
||||
{iconPosition === 'left' && icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<Label>{label}</Label>
|
||||
{iconPosition === 'right' && icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
</StyledChip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledChip = styled.div<ChipProps>`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 100px;
|
||||
font-weight: 500;
|
||||
cursor: ${({ state }) => state === 'disabled' ? 'not-allowed' : 'pointer'};
|
||||
transition: all 0.2s;
|
||||
|
||||
/* Size variants */
|
||||
${({ size }) => {
|
||||
switch (size) {
|
||||
case 'sm': return `padding: 4px 12px; font-size: 12px;`;
|
||||
case 'lg': return `padding: 12px 20px; font-size: 16px;`;
|
||||
default: return `padding: 8px 16px; font-size: 14px;`;
|
||||
}
|
||||
}}
|
||||
|
||||
/* State variants */
|
||||
${({ state }) => {
|
||||
switch (state) {
|
||||
case 'hover':
|
||||
return `background: #E0F2FF; color: #0066FF;`;
|
||||
case 'active':
|
||||
return `background: #0066FF; color: #FFFFFF;`;
|
||||
case 'disabled':
|
||||
return `background: #F0F0F0; color: #A0A0A0; opacity: 0.6;`;
|
||||
default:
|
||||
return `background: #F0F0F0; color: #333333;`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
```
|
||||
|
||||
### 7. Accessibility (a11y)
|
||||
|
||||
**Add ARIA Attributes**:
|
||||
|
||||
```typescript
|
||||
const AccessibleButton: FC<ButtonProps> = ({
|
||||
children,
|
||||
disabled,
|
||||
loading,
|
||||
ariaLabel,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel || (typeof children === 'string' ? children : undefined)}
|
||||
aria-disabled={disabled || loading}
|
||||
aria-busy={loading}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// Icon buttons MUST have aria-label
|
||||
const IconButton: FC<IconButtonProps> = ({ icon, onClick, ariaLabel }) => {
|
||||
return (
|
||||
<button
|
||||
aria-label={ariaLabel} // Required for screen readers
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Semantic HTML**:
|
||||
```typescript
|
||||
// ❌ Wrong: div soup
|
||||
<div onClick={onClick}>Submit</div>
|
||||
|
||||
// ✅ Correct: semantic button
|
||||
<button onClick={onClick}>Submit</button>
|
||||
|
||||
// ❌ Wrong: generic div
|
||||
<div>Card Title</div>
|
||||
|
||||
// ✅ Correct: heading
|
||||
<h2>Card Title</h2>
|
||||
```
|
||||
|
||||
### 8. Storybook Integration
|
||||
|
||||
**Generate Storybook Stories**:
|
||||
|
||||
```typescript
|
||||
// Button.stories.tsx
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import Button from './Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'outline'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['sm', 'md', 'lg'],
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
fullWidth: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
size: 'md',
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
leftIcon: <IconArrowLeft />,
|
||||
rightIcon: <IconArrowRight />,
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: true,
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
disabled: true,
|
||||
children: 'Button',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 9. Testing
|
||||
|
||||
**Generate Component Tests**:
|
||||
|
||||
```typescript
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import Button from './Button';
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click me</Button>);
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button disabled onClick={handleClick}>Click me</Button>);
|
||||
|
||||
fireEvent.click(screen.getByText('Click me'));
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders loading state correctly', () => {
|
||||
render(<Button loading>Click me</Button>);
|
||||
expect(screen.queryByText('Click me')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icons correctly', () => {
|
||||
render(
|
||||
<Button leftIcon={<span data-testid="left-icon">←</span>}>
|
||||
Click me
|
||||
</Button>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('left-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 10. Code Generation Automation
|
||||
|
||||
**Full Pipeline Script**:
|
||||
|
||||
```typescript
|
||||
import { FigmaImporter } from './figma-importer';
|
||||
import { generateReactComponent } from './react-generator';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
async function figmaToReact(fileKey: string, componentName: string) {
|
||||
// 1. Fetch component from Figma
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
const component = findComponentByName(file.document, componentName);
|
||||
|
||||
if (!component) {
|
||||
throw new Error(`Component "${componentName}" not found`);
|
||||
}
|
||||
|
||||
// 2. Analyze component structure
|
||||
const analysis = analyzeComponent(component);
|
||||
|
||||
// 3. Generate React component code
|
||||
const reactCode = generateReactComponent(analysis);
|
||||
|
||||
// 4. Generate TypeScript types
|
||||
const types = generateTypeScriptTypes(analysis);
|
||||
|
||||
// 5. Generate styled-components
|
||||
const styles = generateStyledComponents(analysis);
|
||||
|
||||
// 6. Generate Storybook story
|
||||
const story = generateStorybook(analysis);
|
||||
|
||||
// 7. Generate tests
|
||||
const tests = generateTests(analysis);
|
||||
|
||||
// 8. Save files
|
||||
const componentDir = `./src/components/${analysis.name}`;
|
||||
await fs.mkdir(componentDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(`${componentDir}/${analysis.name}.tsx`, reactCode);
|
||||
await fs.writeFile(`${componentDir}/${analysis.name}.types.ts`, types);
|
||||
await fs.writeFile(`${componentDir}/${analysis.name}.styles.ts`, styles);
|
||||
await fs.writeFile(`${componentDir}/${analysis.name}.stories.tsx`, story);
|
||||
await fs.writeFile(`${componentDir}/${analysis.name}.test.tsx`, tests);
|
||||
await fs.writeFile(`${componentDir}/index.ts`, `export { default } from './${analysis.name}';`);
|
||||
|
||||
console.log(`✅ Generated React component: ${analysis.name}`);
|
||||
console.log(`📁 Location: ${componentDir}`);
|
||||
}
|
||||
|
||||
// Usage
|
||||
figmaToReact('ABC123XYZ456', 'Button').catch(console.error);
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Ask about Figma file and component to convert
|
||||
2. Fetch component metadata from Figma API
|
||||
3. Analyze component structure and variants
|
||||
4. Ask about styling approach (styled-components, CSS modules, Tailwind)
|
||||
5. Generate TypeScript component with props interface
|
||||
6. Convert Figma styles to CSS/styled-components
|
||||
7. Add responsive breakpoints if needed
|
||||
8. Generate Storybook stories for all variants
|
||||
9. Generate unit tests
|
||||
10. Save all generated files and provide usage examples
|
||||
|
||||
## When to Use
|
||||
|
||||
- Converting Figma designs to React components
|
||||
- Building design systems from Figma
|
||||
- Automating component creation from mockups
|
||||
- Ensuring pixel-perfect implementation
|
||||
- Syncing Figma variants with React props
|
||||
- Generating Storybook documentation from designs
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Type Safety**: Always generate TypeScript interfaces
|
||||
2. **Variants**: Map Figma component properties to React props
|
||||
3. **Accessibility**: Include ARIA attributes and semantic HTML
|
||||
4. **Responsive**: Generate mobile-first responsive styles
|
||||
5. **Testing**: Create comprehensive unit tests
|
||||
6. **Documentation**: Generate Storybook stories automatically
|
||||
7. **Naming**: Use PascalCase for components, match Figma names
|
||||
8. **Optimization**: Extract common styles to theme tokens
|
||||
|
||||
Transform Figma designs into production-ready React components!
|
||||
815
commands/figma-tokens.md
Normal file
815
commands/figma-tokens.md
Normal file
@@ -0,0 +1,815 @@
|
||||
# /specweave-figma:tokens
|
||||
|
||||
Extract design tokens from Figma and generate token files for theme configuration (CSS variables, JavaScript, JSON, SCSS).
|
||||
|
||||
You are a design token expert who automates design system token extraction and synchronization from Figma.
|
||||
|
||||
## Your Task
|
||||
|
||||
Extract design tokens (colors, typography, spacing, shadows, borders) from Figma and generate production-ready token files in multiple formats.
|
||||
|
||||
### 1. Design Token Standards
|
||||
|
||||
**W3C Design Tokens Format** (Recommended):
|
||||
```json
|
||||
{
|
||||
"colors": {
|
||||
"brand": {
|
||||
"primary": {
|
||||
"$type": "color",
|
||||
"$value": "#0066FF",
|
||||
"$description": "Primary brand color"
|
||||
},
|
||||
"secondary": {
|
||||
"$type": "color",
|
||||
"$value": "#00CC66"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"heading": {
|
||||
"h1": {
|
||||
"$type": "typography",
|
||||
"$value": {
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": "48px",
|
||||
"fontWeight": 700,
|
||||
"lineHeight": 1.2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Token Categories**:
|
||||
- Colors (brand, semantic, grayscale)
|
||||
- Typography (font families, sizes, weights, line heights)
|
||||
- Spacing (margins, paddings, gaps)
|
||||
- Border radius (corners)
|
||||
- Shadows (elevations)
|
||||
- Borders (widths, styles)
|
||||
- Breakpoints (responsive)
|
||||
- Z-index (layering)
|
||||
- Transitions (animations)
|
||||
|
||||
### 2. Figma Token Extraction
|
||||
|
||||
**Extract Color Tokens from Styles**:
|
||||
|
||||
```typescript
|
||||
import { FigmaImporter } from './figma-importer';
|
||||
|
||||
interface ColorToken {
|
||||
name: string;
|
||||
value: string;
|
||||
rgba: { r: number; g: number; b: number; a: number };
|
||||
type: 'solid' | 'gradient';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function extractColorTokens(fileKey: string): Promise<ColorToken[]> {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
const styles = await importer.fetchStyles();
|
||||
|
||||
const colorTokens: ColorToken[] = [];
|
||||
|
||||
for (const style of styles) {
|
||||
if (style.style_type === 'FILL') {
|
||||
const node = findNodeById(file.document, style.node_id);
|
||||
|
||||
if (node?.fills?.[0]) {
|
||||
const fill = node.fills[0];
|
||||
|
||||
if (fill.type === 'SOLID') {
|
||||
colorTokens.push({
|
||||
name: style.name,
|
||||
value: rgbaToHex(fill.color, fill.opacity),
|
||||
rgba: {
|
||||
r: fill.color.r,
|
||||
g: fill.color.g,
|
||||
b: fill.color.b,
|
||||
a: fill.opacity ?? 1,
|
||||
},
|
||||
type: 'solid',
|
||||
description: style.description || undefined,
|
||||
});
|
||||
} else if (fill.type === 'GRADIENT_LINEAR') {
|
||||
colorTokens.push({
|
||||
name: style.name,
|
||||
value: convertGradientToCSS(fill),
|
||||
rgba: fill.gradientStops[0].color,
|
||||
type: 'gradient',
|
||||
description: style.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return colorTokens;
|
||||
}
|
||||
|
||||
function rgbaToHex(color: { r: number; g: number; b: number }, opacity = 1): string {
|
||||
const r = Math.round(color.r * 255);
|
||||
const g = Math.round(color.g * 255);
|
||||
const b = Math.round(color.b * 255);
|
||||
|
||||
if (opacity < 1) {
|
||||
const a = Math.round(opacity * 255);
|
||||
return `#${[r, g, b, a].map(x => x.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
```
|
||||
|
||||
**Extract Typography Tokens**:
|
||||
|
||||
```typescript
|
||||
interface TypographyToken {
|
||||
name: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: number;
|
||||
lineHeight: number;
|
||||
letterSpacing?: number;
|
||||
textTransform?: 'none' | 'uppercase' | 'lowercase' | 'capitalize';
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function extractTypographyTokens(fileKey: string): Promise<TypographyToken[]> {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
const styles = await importer.fetchStyles();
|
||||
|
||||
const typographyTokens: TypographyToken[] = [];
|
||||
|
||||
for (const style of styles) {
|
||||
if (style.style_type === 'TEXT') {
|
||||
const node = findNodeById(file.document, style.node_id);
|
||||
|
||||
if (node?.style) {
|
||||
typographyTokens.push({
|
||||
name: style.name,
|
||||
fontFamily: node.style.fontFamily,
|
||||
fontSize: node.style.fontSize,
|
||||
fontWeight: node.style.fontWeight,
|
||||
lineHeight: node.style.lineHeightPx / node.style.fontSize,
|
||||
letterSpacing: node.style.letterSpacing || undefined,
|
||||
textTransform: node.style.textCase?.toLowerCase() as any,
|
||||
description: style.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return typographyTokens;
|
||||
}
|
||||
```
|
||||
|
||||
**Extract Spacing Tokens from Auto Layout**:
|
||||
|
||||
```typescript
|
||||
interface SpacingToken {
|
||||
name: string;
|
||||
value: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function extractSpacingTokens(fileKey: string): Promise<SpacingToken[]> {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
|
||||
const spacingValues = new Set<number>();
|
||||
|
||||
// Traverse all frames with auto layout
|
||||
traverseNodes(file.document, (node) => {
|
||||
if (node.layoutMode) {
|
||||
// Item spacing (gap)
|
||||
if (node.itemSpacing) {
|
||||
spacingValues.add(node.itemSpacing);
|
||||
}
|
||||
|
||||
// Padding
|
||||
if (node.paddingLeft) spacingValues.add(node.paddingLeft);
|
||||
if (node.paddingRight) spacingValues.add(node.paddingRight);
|
||||
if (node.paddingTop) spacingValues.add(node.paddingTop);
|
||||
if (node.paddingBottom) spacingValues.add(node.paddingBottom);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate semantic names (4px → xs, 8px → sm, etc.)
|
||||
const sortedSpacing = Array.from(spacingValues).sort((a, b) => a - b);
|
||||
|
||||
const sizeMap = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
|
||||
|
||||
return sortedSpacing.map((value, index) => ({
|
||||
name: sizeMap[index] || `spacing-${index}`,
|
||||
value,
|
||||
description: `${value}px spacing`,
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
**Extract Shadow Tokens (Elevation)**:
|
||||
|
||||
```typescript
|
||||
interface ShadowToken {
|
||||
name: string;
|
||||
value: string; // CSS box-shadow value
|
||||
layers: Array<{
|
||||
x: number;
|
||||
y: number;
|
||||
blur: number;
|
||||
spread: number;
|
||||
color: string;
|
||||
}>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function extractShadowTokens(fileKey: string): Promise<ShadowToken[]> {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
const styles = await importer.fetchStyles();
|
||||
|
||||
const shadowTokens: ShadowToken[] = [];
|
||||
|
||||
for (const style of styles) {
|
||||
if (style.style_type === 'EFFECT') {
|
||||
const node = findNodeById(file.document, style.node_id);
|
||||
|
||||
if (node?.effects) {
|
||||
const shadows = node.effects.filter(
|
||||
(e: any) => e.type === 'DROP_SHADOW' || e.type === 'INNER_SHADOW'
|
||||
);
|
||||
|
||||
if (shadows.length > 0) {
|
||||
const layers = shadows.map((shadow: any) => ({
|
||||
x: shadow.offset.x,
|
||||
y: shadow.offset.y,
|
||||
blur: shadow.radius,
|
||||
spread: shadow.spread || 0,
|
||||
color: rgbaToHex(shadow.color, shadow.color.a),
|
||||
}));
|
||||
|
||||
const cssValue = layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.x}px ${layer.y}px ${layer.blur}px ${layer.spread}px ${layer.color}`
|
||||
)
|
||||
.join(', ');
|
||||
|
||||
shadowTokens.push({
|
||||
name: style.name,
|
||||
value: cssValue,
|
||||
layers,
|
||||
description: style.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shadowTokens;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Token Organization
|
||||
|
||||
**Hierarchical Token Structure**:
|
||||
|
||||
```
|
||||
tokens/
|
||||
├── global/
|
||||
│ ├── colors.json # Brand colors, primitives
|
||||
│ ├── typography.json # Font scales
|
||||
│ ├── spacing.json # Spacing scale
|
||||
│ └── shadows.json # Elevation scale
|
||||
├── semantic/
|
||||
│ ├── colors.json # Semantic colors (success, error, warning)
|
||||
│ ├── components.json # Component-specific tokens
|
||||
│ └── themes.json # Light/dark theme mappings
|
||||
└── platform/
|
||||
├── web.json # Web-specific tokens (CSS variables)
|
||||
├── ios.json # iOS-specific (Swift)
|
||||
└── android.json # Android-specific (XML)
|
||||
```
|
||||
|
||||
**Naming Convention** (BEM-inspired):
|
||||
```
|
||||
category-element-modifier-state
|
||||
|
||||
Examples:
|
||||
- color-brand-primary
|
||||
- color-semantic-success
|
||||
- color-text-primary
|
||||
- typography-heading-h1
|
||||
- spacing-component-button-padding
|
||||
- shadow-elevation-1
|
||||
- border-radius-sm
|
||||
```
|
||||
|
||||
### 4. Token File Formats
|
||||
|
||||
**CSS Variables** (Most Common):
|
||||
|
||||
```css
|
||||
/* tokens/global/colors.css */
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--color-brand-primary: #0066FF;
|
||||
--color-brand-secondary: #00CC66;
|
||||
--color-brand-tertiary: #FF6600;
|
||||
|
||||
/* Semantic Colors */
|
||||
--color-semantic-success: #00CC66;
|
||||
--color-semantic-error: #FF3333;
|
||||
--color-semantic-warning: #FFCC00;
|
||||
--color-semantic-info: #0066FF;
|
||||
|
||||
/* Grayscale */
|
||||
--color-gray-50: #F9FAFB;
|
||||
--color-gray-100: #F3F4F6;
|
||||
--color-gray-200: #E5E7EB;
|
||||
--color-gray-300: #D1D5DB;
|
||||
--color-gray-400: #9CA3AF;
|
||||
--color-gray-500: #6B7280;
|
||||
--color-gray-600: #4B5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1F2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: 'Inter', -apple-system, sans-serif;
|
||||
--font-family-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--font-size-xs: 0.75rem; /* 12px */
|
||||
--font-size-sm: 0.875rem; /* 14px */
|
||||
--font-size-base: 1rem; /* 16px */
|
||||
--font-size-lg: 1.125rem; /* 18px */
|
||||
--font-size-xl: 1.25rem; /* 20px */
|
||||
--font-size-2xl: 1.5rem; /* 24px */
|
||||
--font-size-3xl: 1.875rem; /* 30px */
|
||||
--font-size-4xl: 2.25rem; /* 36px */
|
||||
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Spacing */
|
||||
--spacing-xs: 0.25rem; /* 4px */
|
||||
--spacing-sm: 0.5rem; /* 8px */
|
||||
--spacing-md: 1rem; /* 16px */
|
||||
--spacing-lg: 1.5rem; /* 24px */
|
||||
--spacing-xl: 2rem; /* 32px */
|
||||
--spacing-2xl: 3rem; /* 48px */
|
||||
--spacing-3xl: 4rem; /* 64px */
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-xl: 1rem; /* 16px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 250ms;
|
||||
--transition-slow: 350ms;
|
||||
}
|
||||
```
|
||||
|
||||
**JavaScript/TypeScript**:
|
||||
|
||||
```typescript
|
||||
// tokens/global/colors.ts
|
||||
export const colors = {
|
||||
brand: {
|
||||
primary: '#0066FF',
|
||||
secondary: '#00CC66',
|
||||
tertiary: '#FF6600',
|
||||
},
|
||||
semantic: {
|
||||
success: '#00CC66',
|
||||
error: '#FF3333',
|
||||
warning: '#FFCC00',
|
||||
info: '#0066FF',
|
||||
},
|
||||
gray: {
|
||||
50: '#F9FAFB',
|
||||
100: '#F3F4F6',
|
||||
200: '#E5E7EB',
|
||||
300: '#D1D5DB',
|
||||
400: '#9CA3AF',
|
||||
500: '#6B7280',
|
||||
600: '#4B5563',
|
||||
700: '#374151',
|
||||
800: '#1F2937',
|
||||
900: '#111827',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// tokens/global/typography.ts
|
||||
export const typography = {
|
||||
fontFamily: {
|
||||
sans: "'Inter', -apple-system, sans-serif",
|
||||
mono: "'JetBrains Mono', monospace",
|
||||
},
|
||||
fontSize: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
},
|
||||
fontWeight: {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
},
|
||||
lineHeight: {
|
||||
tight: 1.25,
|
||||
normal: 1.5,
|
||||
relaxed: 1.75,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// tokens/index.ts
|
||||
export { colors } from './global/colors';
|
||||
export { typography } from './global/typography';
|
||||
export { spacing } from './global/spacing';
|
||||
export { shadows } from './global/shadows';
|
||||
|
||||
// Type-safe token access
|
||||
export type ColorToken = keyof typeof colors.brand | keyof typeof colors.semantic;
|
||||
```
|
||||
|
||||
**JSON** (W3C Design Tokens Format):
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://design-tokens.org/schemas/v1.0.0/design-tokens.schema.json",
|
||||
"colors": {
|
||||
"brand": {
|
||||
"primary": {
|
||||
"$type": "color",
|
||||
"$value": "#0066FF",
|
||||
"$description": "Primary brand color used for main actions and links"
|
||||
},
|
||||
"secondary": {
|
||||
"$type": "color",
|
||||
"$value": "#00CC66",
|
||||
"$description": "Secondary brand color for accents"
|
||||
}
|
||||
},
|
||||
"semantic": {
|
||||
"success": {
|
||||
"$type": "color",
|
||||
"$value": "{colors.brand.secondary}",
|
||||
"$description": "Success state color (references brand.secondary)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"heading": {
|
||||
"h1": {
|
||||
"$type": "typography",
|
||||
"$value": {
|
||||
"fontFamily": "{typography.fontFamily.sans}",
|
||||
"fontSize": "{typography.fontSize.4xl}",
|
||||
"fontWeight": "{typography.fontWeight.bold}",
|
||||
"lineHeight": "{typography.lineHeight.tight}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**SCSS Variables**:
|
||||
|
||||
```scss
|
||||
// tokens/global/_colors.scss
|
||||
$color-brand-primary: #0066FF;
|
||||
$color-brand-secondary: #00CC66;
|
||||
|
||||
$color-gray-50: #F9FAFB;
|
||||
$color-gray-100: #F3F4F6;
|
||||
// ...
|
||||
|
||||
// tokens/global/_typography.scss
|
||||
$font-family-sans: 'Inter', -apple-system, sans-serif;
|
||||
$font-size-base: 1rem;
|
||||
$font-weight-bold: 700;
|
||||
|
||||
// tokens/_index.scss
|
||||
@forward 'global/colors';
|
||||
@forward 'global/typography';
|
||||
@forward 'global/spacing';
|
||||
```
|
||||
|
||||
**Tailwind CSS Config**:
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
const { colors, typography, spacing, shadows } = require('./tokens');
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: colors.brand,
|
||||
semantic: colors.semantic,
|
||||
gray: colors.gray,
|
||||
},
|
||||
fontFamily: typography.fontFamily,
|
||||
fontSize: typography.fontSize,
|
||||
fontWeight: typography.fontWeight,
|
||||
lineHeight: typography.lineHeight,
|
||||
spacing: spacing,
|
||||
boxShadow: shadows,
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Token Generation Automation
|
||||
|
||||
**Complete Pipeline**:
|
||||
|
||||
```typescript
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
interface TokenGeneratorConfig {
|
||||
fileKey: string;
|
||||
outputDir: string;
|
||||
formats: Array<'css' | 'scss' | 'js' | 'ts' | 'json' | 'tailwind'>;
|
||||
}
|
||||
|
||||
async function generateTokens(config: TokenGeneratorConfig) {
|
||||
const { fileKey, outputDir, formats } = config;
|
||||
|
||||
// 1. Extract tokens from Figma
|
||||
const colorTokens = await extractColorTokens(fileKey);
|
||||
const typographyTokens = await extractTypographyTokens(fileKey);
|
||||
const spacingTokens = await extractSpacingTokens(fileKey);
|
||||
const shadowTokens = await extractShadowTokens(fileKey);
|
||||
|
||||
const allTokens = {
|
||||
colors: colorTokens,
|
||||
typography: typographyTokens,
|
||||
spacing: spacingTokens,
|
||||
shadows: shadowTokens,
|
||||
};
|
||||
|
||||
// 2. Create output directory
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// 3. Generate formats
|
||||
if (formats.includes('css')) {
|
||||
const css = generateCSSVariables(allTokens);
|
||||
await fs.writeFile(path.join(outputDir, 'tokens.css'), css);
|
||||
}
|
||||
|
||||
if (formats.includes('scss')) {
|
||||
const scss = generateSCSSVariables(allTokens);
|
||||
await fs.writeFile(path.join(outputDir, '_tokens.scss'), scss);
|
||||
}
|
||||
|
||||
if (formats.includes('js') || formats.includes('ts')) {
|
||||
const js = generateJavaScript(allTokens);
|
||||
const ext = formats.includes('ts') ? '.ts' : '.js';
|
||||
await fs.writeFile(path.join(outputDir, `tokens${ext}`), js);
|
||||
}
|
||||
|
||||
if (formats.includes('json')) {
|
||||
const json = generateW3CTokens(allTokens);
|
||||
await fs.writeFile(path.join(outputDir, 'tokens.json'), json);
|
||||
}
|
||||
|
||||
if (formats.includes('tailwind')) {
|
||||
const tailwind = generateTailwindConfig(allTokens);
|
||||
await fs.writeFile(path.join(outputDir, 'tailwind.tokens.js'), tailwind);
|
||||
}
|
||||
|
||||
console.log(`✅ Generated design tokens in ${formats.join(', ')} format(s)`);
|
||||
}
|
||||
|
||||
function generateCSSVariables(tokens: any): string {
|
||||
let css = ':root {\n';
|
||||
|
||||
// Colors
|
||||
css += ' /* Colors */\n';
|
||||
tokens.colors.forEach((token: any) => {
|
||||
css += ` --color-${token.name.toLowerCase().replace(/\s+/g, '-')}: ${token.value};\n`;
|
||||
});
|
||||
|
||||
// Typography
|
||||
css += '\n /* Typography */\n';
|
||||
tokens.typography.forEach((token: any) => {
|
||||
const name = token.name.toLowerCase().replace(/\s+/g, '-');
|
||||
css += ` --font-family-${name}: ${token.fontFamily};\n`;
|
||||
css += ` --font-size-${name}: ${token.fontSize}px;\n`;
|
||||
css += ` --font-weight-${name}: ${token.fontWeight};\n`;
|
||||
css += ` --line-height-${name}: ${token.lineHeight};\n`;
|
||||
});
|
||||
|
||||
// Spacing
|
||||
css += '\n /* Spacing */\n';
|
||||
tokens.spacing.forEach((token: any) => {
|
||||
css += ` --spacing-${token.name}: ${token.value}px;\n`;
|
||||
});
|
||||
|
||||
// Shadows
|
||||
css += '\n /* Shadows */\n';
|
||||
tokens.shadows.forEach((token: any) => {
|
||||
css += ` --shadow-${token.name.toLowerCase().replace(/\s+/g, '-')}: ${token.value};\n`;
|
||||
});
|
||||
|
||||
css += '}\n';
|
||||
|
||||
return css;
|
||||
}
|
||||
|
||||
function generateJavaScript(tokens: any): string {
|
||||
const js = `
|
||||
export const colors = {
|
||||
${tokens.colors.map((t: any) => ` '${t.name}': '${t.value}',`).join('\n')}
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
${tokens.typography.map((t: any) => ` '${t.name}': {
|
||||
fontFamily: '${t.fontFamily}',
|
||||
fontSize: ${t.fontSize},
|
||||
fontWeight: ${t.fontWeight},
|
||||
lineHeight: ${t.lineHeight},
|
||||
},`).join('\n')}
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
${tokens.spacing.map((t: any) => ` '${t.name}': ${t.value},`).join('\n')}
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
${tokens.shadows.map((t: any) => ` '${t.name}': '${t.value}',`).join('\n')}
|
||||
};
|
||||
`;
|
||||
|
||||
return js.trim();
|
||||
}
|
||||
|
||||
// Usage
|
||||
generateTokens({
|
||||
fileKey: 'ABC123XYZ456',
|
||||
outputDir: './src/design-tokens',
|
||||
formats: ['css', 'ts', 'json', 'tailwind'],
|
||||
}).catch(console.error);
|
||||
```
|
||||
|
||||
### 6. Token Synchronization (Watch Mode)
|
||||
|
||||
**Auto-sync on Figma Changes**:
|
||||
|
||||
```typescript
|
||||
import { watch } from 'fs';
|
||||
|
||||
async function watchFigmaTokens(fileKey: string, outputDir: string, pollInterval = 60000) {
|
||||
let lastVersion: string | null = null;
|
||||
|
||||
setInterval(async () => {
|
||||
const importer = new FigmaImporter({
|
||||
accessToken: process.env.FIGMA_ACCESS_TOKEN!,
|
||||
fileKey,
|
||||
});
|
||||
|
||||
const file = await importer.fetchFile();
|
||||
|
||||
if (lastVersion && file.version !== lastVersion) {
|
||||
console.log(`🔄 Figma file updated (v${file.version}). Re-generating tokens...`);
|
||||
|
||||
await generateTokens({
|
||||
fileKey,
|
||||
outputDir,
|
||||
formats: ['css', 'ts', 'json'],
|
||||
});
|
||||
|
||||
console.log('✅ Tokens synchronized!');
|
||||
}
|
||||
|
||||
lastVersion = file.version;
|
||||
}, pollInterval);
|
||||
|
||||
console.log(`👀 Watching Figma file for changes (polling every ${pollInterval / 1000}s)...`);
|
||||
}
|
||||
|
||||
// Usage
|
||||
watchFigmaTokens('ABC123XYZ456', './src/design-tokens', 60000); // Check every minute
|
||||
```
|
||||
|
||||
### 7. Theme Support (Light/Dark Mode)
|
||||
|
||||
**Generate Theme Tokens**:
|
||||
|
||||
```typescript
|
||||
// tokens/themes/light.ts
|
||||
export const lightTheme = {
|
||||
colors: {
|
||||
background: '#FFFFFF',
|
||||
foreground: '#111827',
|
||||
primary: '#0066FF',
|
||||
secondary: '#6B7280',
|
||||
border: '#E5E7EB',
|
||||
},
|
||||
};
|
||||
|
||||
// tokens/themes/dark.ts
|
||||
export const darkTheme = {
|
||||
colors: {
|
||||
background: '#111827',
|
||||
foreground: '#F9FAFB',
|
||||
primary: '#3B82F6',
|
||||
secondary: '#9CA3AF',
|
||||
border: '#374151',
|
||||
},
|
||||
};
|
||||
|
||||
// CSS Variables with theme support
|
||||
:root {
|
||||
--color-background: #FFFFFF;
|
||||
--color-foreground: #111827;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-background: #111827;
|
||||
--color-foreground: #F9FAFB;
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Ask about Figma file and token extraction preferences
|
||||
2. Fetch color, typography, spacing, and shadow styles from Figma
|
||||
3. Analyze and categorize tokens (brand, semantic, primitives)
|
||||
4. Ask about output formats (CSS, SCSS, JS, JSON, Tailwind)
|
||||
5. Generate hierarchical token structure
|
||||
6. Create token files in requested formats
|
||||
7. Set up theme support if needed (light/dark modes)
|
||||
8. Provide integration examples for each format
|
||||
9. Optionally set up watch mode for auto-sync
|
||||
10. Generate documentation with token usage examples
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up design systems from Figma
|
||||
- Synchronizing design tokens across platforms (web, iOS, Android)
|
||||
- Migrating from hardcoded values to token-based theming
|
||||
- Implementing light/dark mode support
|
||||
- Automating design-to-code token updates
|
||||
- Standardizing design values across teams
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Organization**: Use hierarchical structure (global → semantic → component)
|
||||
2. **Naming**: Follow consistent naming conventions (BEM or similar)
|
||||
3. **Formats**: Generate multiple formats for different use cases
|
||||
4. **Aliasing**: Use token references for semantic tokens (W3C format)
|
||||
5. **Documentation**: Include descriptions from Figma styles
|
||||
6. **Versioning**: Track Figma file versions in metadata
|
||||
7. **Automation**: Set up CI/CD for token synchronization
|
||||
8. **Validation**: Validate token values before deployment
|
||||
|
||||
Extract and sync design tokens with production-ready automation!
|
||||
Reference in New Issue
Block a user