UNPKG

claude-code-figma

Version:

An AI-first CLI tool designed specifically for Claude Code to extract and transform Figma designs into code

1,493 lines (1,238 loc) 61.8 kB
#!/usr/bin/env node import { program } from 'commander'; import fs from 'fs'; import path from 'path'; import open from 'open'; import inquirer from 'inquirer'; import dotenv from 'dotenv'; import ora from 'ora'; import { fileURLToPath } from 'url'; import fetch from 'node-fetch'; import FigmaClient from './figma-client.js'; // Load environment variables dotenv.config(); const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.figma-to-code'); const TOKEN_PATH = path.join(CONFIG_DIR, 'auth.json'); // Ensure config directory exists if (!fs.existsSync(CONFIG_DIR)) { fs.mkdirSync(CONFIG_DIR, { recursive: true }); } // Function to get the stored token or prompt for authentication async function getAuthToken() { // Check if token exists if (fs.existsSync(TOKEN_PATH)) { try { const tokenData = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8')); return tokenData.token; } catch (error) { console.error('Error reading auth token:', error.message); } } // If no token, guide the user through authentication console.log('No Figma authentication token found.'); console.log('Please follow these steps to authenticate:'); console.log('1. Go to https://www.figma.com/developers/api'); console.log('2. Log in and create a personal access token'); console.log('3. Copy the token and paste it here'); console.log('\nNote: Make sure you are using a token from the account that has access to the Figma file.'); console.log('If you need to change accounts, you can reset your token anytime with: figma-to-code auth --reset'); const { openBrowser } = await inquirer.prompt([ { type: 'confirm', name: 'openBrowser', message: 'Open the Figma API page in your browser?', default: true } ]); if (openBrowser) { await open('https://www.figma.com/developers/api'); } const { token } = await inquirer.prompt([ { type: 'password', name: 'token', message: 'Paste your Figma personal access token:', validate: input => input.trim() !== '' || 'Token is required' } ]); // Save the token fs.writeFileSync(TOKEN_PATH, JSON.stringify({ token }, null, 2)); console.log('Authentication successful. Token saved.'); return token; } // Parse Figma URL to extract file key and node ID function parseFigmaUrl(url) { try { // Parse the URL const parsedUrl = new URL(url); // Get the pathname to extract file/design key const pathname = parsedUrl.pathname; // Extract node-id from search params const searchParams = new URLSearchParams(parsedUrl.search); let nodeId = searchParams.get('node-id'); // Convert hyphen to colon in node ID if needed (Figma API expects colon format) if (nodeId && nodeId.includes('-')) { nodeId = nodeId.replace('-', ':'); } // Determine if it's a file or design URL and extract the key let fileKey = null; // Split the pathname into segments const segments = pathname.split('/').filter(segment => segment.length > 0); // Handle different Figma URL formats if (segments.length >= 2) { // Format: /file/{key}/... or /design/{key}/... if (segments[0] === 'file' || segments[0] === 'design') { fileKey = segments[1]; } } if (!fileKey) { throw new Error('Could not extract file key from URL. Supported formats: figma.com/file/KEY or figma.com/design/KEY'); } return { fileKey, nodeId }; } catch (error) { if (error.message.includes('Invalid URL')) { throw new Error('Invalid Figma URL. Please provide a valid URL from Figma.'); } throw error; } } // Function to fetch node metadata from Figma async function fetchNodeMetadata(url, verbose = false) { const spinner = ora('Authenticating with Figma...').start(); // Helper for conditional logging based on verbose flag const log = (...args) => { if (verbose) { console.log(...args); } }; try { const token = await getAuthToken(); const figma = new FigmaClient(token, verbose); spinner.text = 'Parsing Figma URL...'; log('Parsing URL:', url); const { fileKey, nodeId } = parseFigmaUrl(url); log('Extracted fileKey:', fileKey); log('Extracted nodeId:', nodeId); // If we have a specific node ID, use the more efficient nodes endpoint if (nodeId) { spinner.text = `Fetching node data for ${nodeId}...`; try { const nodesData = await figma.fileNodes(fileKey, [nodeId]); log('Nodes data retrieved successfully'); // Debug the nodes response only in verbose mode if (verbose) { log('API response:', JSON.stringify(nodesData, null, 2)); } // Check if the node was found if (nodesData.nodes && nodesData.nodes[nodeId]) { spinner.succeed('Fetched node metadata successfully'); return nodesData.nodes[nodeId]; } else { spinner.fail(`Node with ID ${nodeId} not found in response`); // If we have nodes but the specific one wasn't found, show available nodes and exit if (nodesData.nodes && Object.keys(nodesData.nodes).length > 0) { console.error('The node ID format might be different. Available nodes:'); console.error(Object.keys(nodesData.nodes).join(', ')); console.error(`\nPlease use one of these node IDs in your URL.`); } else { console.error('No nodes were found in the response.'); } // Exit the program - this is a fatal error process.exit(1); } } catch (error) { // If this fails and it's a design URL, we might need to try alternative methods if (error.message.includes('404') && url.includes('/design/')) { spinner.fail('Could not access this Figma design node'); console.log('\nThe provided design URL format is not directly supported by the Figma API.'); console.log('Please try with a file URL from the same design (click "Share" and copy the link)'); console.log('The URL should start with: https://www.figma.com/file/'); throw new Error('Could not access Figma design. Please use a file URL instead.'); } throw error; } } // If no node ID is specified, fetch the entire file (with a warning about performance) spinner.text = `Fetching file data for ${fileKey}...`; // Always show these warnings regardless of verbose mode as they're important console.log('Warning: No node ID specified. Fetching entire file, which may be slow for large files.'); console.log('For better performance, specify a node ID in the URL using ?node-id=X:Y'); try { const fileData = await figma.file(fileKey); spinner.succeed('Fetched document metadata successfully'); return fileData; } catch (error) { if (error.message.includes('404') && url.includes('/design/')) { spinner.fail('Could not access this Figma design'); console.log('\nThe provided design URL format is not directly supported by the Figma API.'); console.log('Please try with a file URL from the same design (click "Share" and copy the link)'); console.log('The URL should start with: https://www.figma.com/file/'); throw new Error('Could not access Figma design. Please use a file URL instead.'); } throw error; } } catch (error) { spinner.fail(`Error: ${error.message}`); throw error; } } // Main program program .name('claude-code-figma') .description('AI-first CLI to help Claude Code extract and implement Figma designs') .version('1.0.0') .addHelpText('after', ` Examples: $ claude-code-figma extract https://www.figma.com/file/abcdef123456/MyDesign?node-id=1:2 $ claude-code-figma extract https://www.figma.com/file/abcdef123456/MyDesign?node-id=1:2 --format json $ claude-code-figma extract https://www.figma.com/file/abcdef123456/MyDesign?node-id=1:2 --format yaml $ claude-code-figma extract https://www.figma.com/file/abcdef123456/MyDesign?node-id=1:2 --format bullet $ claude-code-figma init $ claude-code-figma auth --reset Output Formats: summary - Detailed component blueprint with embedded information (default) * Includes descriptive HTML structure with Figma metadata * Provides complete styling information via data attributes * Shows component hierarchy with nested elements * Suggests React component types based on element purpose * Generates Tailwind config for custom colors * Optimized for AI-assisted implementation json - Standard JSON format (raw Figma API data) yaml - YAML format (more compact than JSON) bullet - Hierarchical bullet points for easy reading Claude Code Integration: Run 'claude-code-figma init' with Claude Code to create a customized CLAUDE.md file `); // Add a verify-url command to check if a URL is valid for the Figma API program .command('verify-url <url>') .description('Verify if a Figma URL is supported by the API') .action(async (url) => { try { console.log('Analyzing URL:', url); // Parse the URL const { fileKey, nodeId } = parseFigmaUrl(url); console.log('Extracted:'); console.log(`- File Key: ${fileKey}`); console.log(`- Node ID: ${nodeId || 'None'}`); // Test if the file exists by making a simple API call console.log('\nChecking if file exists in Figma API...'); const token = await getAuthToken(); const spinner = ora('Making API request...').start(); try { const response = await fetch(`https://api.figma.com/v1/files/${fileKey}/nodes?ids=${nodeId || '0:1'}`, { headers: { 'X-Figma-Token': token } }); if (response.ok) { spinner.succeed('URL is valid and accessible!'); console.log('This URL should work with the extract command.'); } else { spinner.fail('URL is not accessible via the Figma API.'); console.log(`API returned status: ${response.status} ${response.statusText}`); if (url.includes('/design/')) { console.log('\nThis appears to be a design URL, which may not be supported by the Figma API.'); console.log('Try using a URL in this format instead:'); console.log('https://www.figma.com/file/KEY/name?node-id=X-Y'); } } } catch (error) { spinner.fail('Error checking URL'); console.error(error.message); } } catch (error) { console.error(`Error: ${error.message}`); process.exit(1); } }); // Helper functions for optimizing Figma data for component mapping function optimizeFigmaData(figmaNode) { if (!figmaNode || !figmaNode.document) { return figmaNode; // Return as-is if not in expected format } // Extract the document node const document = figmaNode.document; // Create the optimized data structure const optimizedData = { originalData: figmaNode, // Keep the original data for reference component: { name: document.name, type: document.type, id: document.id, componentType: guessComponentType(document), componentHints: generateComponentHints(document), properties: extractProperties(document), tailwindClasses: generateTailwindClasses(document), children: processChildren(document.children), styles: extractStyles(document, figmaNode.styles), variants: extractVariants(document), interactionPatterns: extractInteractions(document), implementationGuide: generateImplementationGuide(document) } }; return optimizedData; } // Helper functions for optimization process function guessComponentType(node) { // Try to determine what kind of component this is (button, modal, card, etc.) // based on node properties, name, and children const nameLower = node.name?.toLowerCase() || ''; if (nameLower.includes('button') || (node.type === 'INSTANCE' && nameLower.includes('btn'))) { return 'button'; } if (nameLower.includes('modal') || nameLower.includes('dialog') || (node.children && node.children.some(c => c.name?.toLowerCase().includes('modal')))) { return 'modal'; } if (nameLower.includes('card') || (node.cornerRadius && node.cornerRadius > 0 && node.children)) { return 'card'; } if (nameLower.includes('input') || nameLower.includes('field') || nameLower.includes('form')) { return 'input'; } if (nameLower.includes('alert') || nameLower.includes('notification') || nameLower.includes('toast')) { return 'alert'; } if (nameLower.includes('cancel')) { return 'confirmation-dialog'; } return 'unknown'; } function generateComponentHints(node) { // Generate search hints for finding matching components in the host project // Based on node name, type, and other characteristics const hints = []; // Add name-based hints if (node.name) { hints.push(node.name); // Add common component naming patterns const nameLower = node.name.toLowerCase(); if (nameLower.includes('cancel')) { hints.push('Cancel', 'CancelDialog', 'Confirmation', 'ConfirmationDialog'); } } // Add type-based hints if (node.type === 'INSTANCE') { // If it's an instance, we can use the component name as a hint hints.push(`Component: ${node.name}`); } // Add style-based hints if (node.fills && node.fills.length > 0) { const colors = node.fills .filter(fill => fill.type === 'SOLID') .map(fill => { const color = fill.color; return `rgb(${Math.round(color.r*255)}, ${Math.round(color.g*255)}, ${Math.round(color.b*255)})`; }); if (colors.length > 0) { hints.push(`Colors: ${colors.join(', ')}`); } } return hints; } function extractProperties(node) { // Extract important properties that would be useful for component matching const props = {}; // Extract basic properties if (node.backgroundColor) { props.backgroundColor = formatColor(node.backgroundColor); } if (node.cornerRadius) { props.borderRadius = node.cornerRadius; } if (node.strokes && node.strokes.length > 0) { props.border = { width: node.strokeWeight || 1, style: 'solid', // Default to solid color: formatColor(node.strokes[0].color) }; } // Extract layout properties if (node.layoutMode) { props.layout = { direction: node.layoutMode === 'HORIZONTAL' ? 'row' : 'column', gap: node.itemSpacing || 0, padding: extractPadding(node) }; } return props; } function formatColor(color) { if (!color) return null; const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); const a = color.a !== undefined ? color.a : 1; if (a === 1) { return `rgb(${r}, ${g}, ${b})`; } else { return `rgba(${r}, ${g}, ${b}, ${a})`; } } function extractPadding(node) { const padding = {}; if (node.paddingLeft !== undefined) padding.left = node.paddingLeft; if (node.paddingRight !== undefined) padding.right = node.paddingRight; if (node.paddingTop !== undefined) padding.top = node.paddingTop; if (node.paddingBottom !== undefined) padding.bottom = node.paddingBottom; return padding; } function processChildren(children) { if (!children || !Array.isArray(children)) return []; return children.map(child => { const result = { id: child.id, name: child.name, type: child.type, componentType: guessComponentType(child), properties: extractProperties(child), tailwindClasses: generateTailwindClasses(child) }; // Handle text nodes specially if (child.type === 'TEXT') { result.text = child.characters; result.textStyle = extractTextStyle(child); result.tailwindTextClasses = generateTailwindTextClasses(child); } // Process nested children if (child.children && child.children.length > 0) { result.children = processChildren(child.children); } // Handle component instances if (child.type === 'INSTANCE') { result.componentId = child.componentId; if (child.componentProperties) { result.componentProperties = child.componentProperties; } } return result; }); } function extractTextStyle(textNode) { if (!textNode.style) return {}; return { fontFamily: textNode.style.fontFamily, fontSize: textNode.style.fontSize, fontWeight: textNode.style.fontWeight, lineHeight: textNode.style.lineHeightPx, letterSpacing: textNode.style.letterSpacing, textAlign: textNode.style.textAlignHorizontal?.toLowerCase(), color: textNode.fills && textNode.fills.length > 0 ? formatColor(textNode.fills[0].color) : null }; } function extractStyles(node, projectStyles) { // Extract style references that could map to design tokens or theme variables const styles = {}; // Extract style references from the node if (node.styles) { Object.entries(node.styles).forEach(([key, value]) => { styles[key] = value; }); } // Extract bound variables from the node if (node.boundVariables) { styles.variables = node.boundVariables; } // Add any project styles that are referenced if (projectStyles) { styles.projectStyles = projectStyles; } return styles; } function extractVariants(node) { // Extract variant information if this is a component instance if (node.type !== 'INSTANCE' || !node.componentProperties) { return {}; } const variants = {}; // Process component properties to extract variant info Object.entries(node.componentProperties).forEach(([key, value]) => { if (value.type === 'VARIANT') { variants[key] = value.value; } }); return variants; } function extractInteractions(node) { if (!node.interactions || node.interactions.length === 0) { return []; } return node.interactions.map(interaction => ({ trigger: interaction.trigger?.type, action: interaction.actions?.[0]?.type, target: interaction.actions?.[0]?.destinationId })); } // Tailwind CSS specific helper functions function generateTailwindClasses(node) { if (!node) return []; const classes = []; // Extract layout classes if (node.layoutMode === 'HORIZONTAL') { classes.push('flex', 'flex-row'); } else if (node.layoutMode === 'VERTICAL') { classes.push('flex', 'flex-col'); } // Extract spacing classes if (node.itemSpacing) { const gap = pxToTailwindSpacing(node.itemSpacing); if (gap) classes.push(`gap-${gap}`); } // Extract padding classes const padding = extractPaddingClasses(node); if (padding.length > 0) { classes.push(...padding); } // Extract background color if (node.backgroundColor) { const bgClass = colorToTailwindClass(node.backgroundColor, 'bg'); if (bgClass) classes.push(bgClass); } // Extract border radius if (node.cornerRadius) { const rounded = pxToTailwindBorderRadius(node.cornerRadius); if (rounded) classes.push(`rounded-${rounded}`); } // Extract border if (node.strokes && node.strokes.length > 0) { const borderClass = colorToTailwindClass(node.strokes[0].color, 'border'); if (borderClass) classes.push(borderClass); if (node.strokeWeight) { const borderWidth = pxToTailwindBorderWidth(node.strokeWeight); if (borderWidth) classes.push(`border-${borderWidth}`); } else { classes.push('border'); } } // Extract width and height if (node.absoluteBoundingBox) { const { width, height } = node.absoluteBoundingBox; if (width) { const widthClass = pxToTailwindSize(width, 'w'); if (widthClass) classes.push(widthClass); } if (height) { const heightClass = pxToTailwindSize(height, 'h'); if (heightClass) classes.push(heightClass); } } // Extract shadow if (node.effects && node.effects.length > 0) { const shadowClasses = extractShadowClasses(node.effects); if (shadowClasses.length > 0) { classes.push(...shadowClasses); } } return classes; } function generateTailwindTextClasses(textNode) { if (!textNode || !textNode.style) return []; const classes = []; // Font family if (textNode.style.fontFamily) { const fontFamily = fontFamilyToTailwind(textNode.style.fontFamily); if (fontFamily) classes.push(fontFamily); } // Font size if (textNode.style.fontSize) { const fontSize = fontSizeToTailwind(textNode.style.fontSize); if (fontSize) classes.push(fontSize); } // Font weight if (textNode.style.fontWeight) { const fontWeight = fontWeightToTailwind(textNode.style.fontWeight); if (fontWeight) classes.push(fontWeight); } // Line height if (textNode.style.lineHeightPx) { const lineHeight = lineHeightToTailwind(textNode.style.lineHeightPx); if (lineHeight) classes.push(lineHeight); } // Letter spacing if (textNode.style.letterSpacing) { const tracking = letterSpacingToTailwind(textNode.style.letterSpacing); if (tracking) classes.push(tracking); } // Text alignment if (textNode.style.textAlignHorizontal) { const textAlign = textAlignToTailwind(textNode.style.textAlignHorizontal); if (textAlign) classes.push(textAlign); } // Text color if (textNode.fills && textNode.fills.length > 0 && textNode.fills[0].color) { const textColor = colorToTailwindClass(textNode.fills[0].color, 'text'); if (textColor) classes.push(textColor); } return classes; } function pxToTailwindSpacing(px) { // Convert pixel values to Tailwind spacing scale if (px <= 0) return null; if (px <= 1) return '0.5'; // 0.125rem = 2px if (px <= 2) return '1'; // 0.25rem = 4px if (px <= 3) return '1.5'; // 0.375rem = 6px if (px <= 5) return '2'; // 0.5rem = 8px if (px <= 7) return '3'; // 0.75rem = 12px if (px <= 10) return '4'; // 1rem = 16px if (px <= 14) return '5'; // 1.25rem = 20px if (px <= 18) return '6'; // 1.5rem = 24px if (px <= 22) return '7'; // 1.75rem = 28px if (px <= 26) return '8'; // 2rem = 32px if (px <= 34) return '10'; // 2.5rem = 40px if (px <= 42) return '12'; // 3rem = 48px if (px <= 56) return '16'; // 4rem = 64px return null; // Use custom size for larger values } function pxToTailwindBorderRadius(px) { if (px <= 0) return null; if (px <= 1) return 'sm'; // 0.125rem = 2px if (px <= 3) return 'DEFAULT'; // 0.25rem = 4px if (px <= 6) return 'md'; // 0.375rem = 6px if (px <= 9) return 'lg'; // 0.5rem = 8px if (px <= 12) return 'xl'; // 0.75rem = 12px if (px <= 16) return '2xl'; // 1rem = 16px if (px <= 20) return '3xl'; // 1.5rem = 24px return 'full'; // Use rounded-full for larger values or exact px } function pxToTailwindBorderWidth(px) { if (px <= 0) return null; if (px <= 1) return 'DEFAULT'; // 1px if (px <= 2) return '2'; // 2px if (px <= 4) return '4'; // 4px if (px <= 8) return '8'; // 8px return null; // Use custom width for larger values } function pxToTailwindSize(px, prefix) { // For w-* and h-* utilities if (px <= 0) return null; // Try to match standard sizes if (Math.abs(px - 16) <= 2) return `${prefix}-4`; // ~16px if (Math.abs(px - 24) <= 2) return `${prefix}-6`; // ~24px if (Math.abs(px - 32) <= 2) return `${prefix}-8`; // ~32px if (Math.abs(px - 40) <= 2) return `${prefix}-10`; // ~40px if (Math.abs(px - 48) <= 2) return `${prefix}-12`; // ~48px if (Math.abs(px - 64) <= 3) return `${prefix}-16`; // ~64px if (Math.abs(px - 80) <= 3) return `${prefix}-20`; // ~80px if (Math.abs(px - 96) <= 3) return `${prefix}-24`; // ~96px if (Math.abs(px - 128) <= 4) return `${prefix}-32`; // ~128px if (Math.abs(px - 160) <= 4) return `${prefix}-40`; // ~160px if (Math.abs(px - 192) <= 4) return `${prefix}-48`; // ~192px if (Math.abs(px - 256) <= 5) return `${prefix}-64`; // ~256px if (Math.abs(px - 320) <= 5) return `${prefix}-80`; // ~320px if (Math.abs(px - 384) <= 5) return `${prefix}-96`; // ~384px // For percentages or special cases if (Math.abs(px - 360) <= 5) return `${prefix}-full`; // could be full width in a container return null; // Use inline style for non-standard sizes } function extractPaddingClasses(node) { let classes = []; // Extract individual padding values if available if (node.paddingLeft !== undefined || node.paddingRight !== undefined || node.paddingTop !== undefined || node.paddingBottom !== undefined) { // Check if all paddings are equal if (node.paddingLeft === node.paddingRight && node.paddingLeft === node.paddingTop && node.paddingLeft === node.paddingBottom && node.paddingLeft !== undefined) { const p = pxToTailwindSpacing(node.paddingLeft); if (p) classes.push(`p-${p}`); } else { // Handle individual paddings if (node.paddingLeft !== undefined) { const pl = pxToTailwindSpacing(node.paddingLeft); if (pl) classes.push(`pl-${pl}`); } if (node.paddingRight !== undefined) { const pr = pxToTailwindSpacing(node.paddingRight); if (pr) classes.push(`pr-${pr}`); } if (node.paddingTop !== undefined) { const pt = pxToTailwindSpacing(node.paddingTop); if (pt) classes.push(`pt-${pt}`); } if (node.paddingBottom !== undefined) { const pb = pxToTailwindSpacing(node.paddingBottom); if (pb) classes.push(`pb-${pb}`); } // Try to simplify with px and py if possible if (node.paddingLeft === node.paddingRight && node.paddingLeft !== undefined) { const px = pxToTailwindSpacing(node.paddingLeft); if (px) { // Remove the individual classes classes = classes.filter(c => !c.startsWith('pl-') && !c.startsWith('pr-')); classes.push(`px-${px}`); } } if (node.paddingTop === node.paddingBottom && node.paddingTop !== undefined) { const py = pxToTailwindSpacing(node.paddingTop); if (py) { // Remove the individual classes classes = classes.filter(c => !c.startsWith('pt-') && !c.startsWith('pb-')); classes.push(`py-${py}`); } } } } return classes; } function extractShadowClasses(effects) { const classes = []; // Look for drop shadow effects const shadows = effects.filter(effect => effect.type === 'DROP_SHADOW' && effect.visible !== false); if (shadows.length > 0) { // Try to find the most prominent shadow let hasShadow = false; for (const shadow of shadows) { // Check for large outer shadow if (shadow.radius >= 16 && shadow.offset.y >= 8) { classes.push('shadow-xl'); hasShadow = true; break; } // Check for medium shadow else if (shadow.radius >= 8 && shadow.offset.y >= 4) { classes.push('shadow-lg'); hasShadow = true; break; } // Check for small shadow else if (shadow.radius >= 3) { classes.push('shadow-md'); hasShadow = true; break; } } // If no specific size was determined but shadows exist if (!hasShadow && shadows.length > 0) { classes.push('shadow'); } } return classes; } function colorToTailwindClass(color, prefix) { if (!color) return null; // Generate RGB values const r = Math.round(color.r * 255); const g = Math.round(color.g * 255); const b = Math.round(color.b * 255); const a = color.a !== undefined ? color.a : 1; // Special case for black and white if (r === 0 && g === 0 && b === 0) { return `${prefix}-black`; } if (r === 255 && g === 255 && b === 255) { return `${prefix}-white`; } // Try to match to closest Tailwind color // These are rough approximations of common Tailwind colors // Grays if (Math.abs(r - g) < 10 && Math.abs(r - b) < 10) { const brightness = (r + g + b) / 3; if (brightness < 30) return `${prefix}-gray-950`; if (brightness < 50) return `${prefix}-gray-900`; if (brightness < 80) return `${prefix}-gray-800`; if (brightness < 110) return `${prefix}-gray-700`; if (brightness < 135) return `${prefix}-gray-600`; if (brightness < 160) return `${prefix}-gray-500`; if (brightness < 185) return `${prefix}-gray-400`; if (brightness < 210) return `${prefix}-gray-300`; if (brightness < 230) return `${prefix}-gray-200`; if (brightness < 245) return `${prefix}-gray-100`; return `${prefix}-gray-50`; } // Reds if (r > 170 && g < 100 && b < 100) { if (r > 240) return `${prefix}-red-500`; if (r > 220) return `${prefix}-red-600`; if (r > 200) return `${prefix}-red-700`; if (r > 180) return `${prefix}-red-800`; return `${prefix}-red-900`; } // Blues if (b > 170 && r < 100 && g < 160) { if (b > 240) return `${prefix}-blue-500`; if (b > 220) return `${prefix}-blue-600`; if (b > 200) return `${prefix}-blue-700`; if (b > 180) return `${prefix}-blue-800`; return `${prefix}-blue-900`; } // For transparency if (a < 0.2) return `${prefix}-transparent`; // If opacity is significant but not full if (a < 0.95) { return `${prefix}-opacity-${Math.round(a * 100)}`; } // Return null for colors that don't match standard Tailwind palette // Claude Code will need to suggest adding a custom color to the Tailwind config return null; } function fontFamilyToTailwind(fontFamily) { if (!fontFamily) return null; const normalizedFont = fontFamily.toLowerCase(); if (normalizedFont.includes('inter')) return 'font-sans'; // Common UI font if (normalizedFont.includes('helvetica') || normalizedFont.includes('arial')) return 'font-sans'; if (normalizedFont.includes('times') || normalizedFont.includes('georgia')) return 'font-serif'; if (normalizedFont.includes('mono') || normalizedFont.includes('courier')) return 'font-mono'; return null; // Custom font family } function fontSizeToTailwind(fontSize) { if (!fontSize) return null; if (fontSize <= 12) return 'text-xs'; if (fontSize <= 14) return 'text-sm'; if (fontSize <= 16) return 'text-base'; if (fontSize <= 18) return 'text-lg'; if (fontSize <= 20) return 'text-xl'; if (fontSize <= 24) return 'text-2xl'; if (fontSize <= 30) return 'text-3xl'; if (fontSize <= 36) return 'text-4xl'; if (fontSize <= 48) return 'text-5xl'; if (fontSize <= 60) return 'text-6xl'; if (fontSize <= 72) return 'text-7xl'; if (fontSize <= 96) return 'text-8xl'; if (fontSize <= 128) return 'text-9xl'; return null; // Custom font size } function fontWeightToTailwind(fontWeight) { if (!fontWeight) return null; if (fontWeight <= 100) return 'font-thin'; if (fontWeight <= 200) return 'font-extralight'; if (fontWeight <= 300) return 'font-light'; if (fontWeight <= 400) return 'font-normal'; if (fontWeight <= 500) return 'font-medium'; if (fontWeight <= 600) return 'font-semibold'; if (fontWeight <= 700) return 'font-bold'; if (fontWeight <= 800) return 'font-extrabold'; if (fontWeight <= 900) return 'font-black'; return 'font-black'; // Maximum weight } function lineHeightToTailwind(lineHeight) { if (!lineHeight) return null; if (lineHeight <= 16) return 'leading-none'; if (lineHeight <= 20) return 'leading-tight'; if (lineHeight <= 24) return 'leading-snug'; if (lineHeight <= 28) return 'leading-normal'; if (lineHeight <= 32) return 'leading-relaxed'; if (lineHeight <= 40) return 'leading-loose'; return null; // Custom line height } function letterSpacingToTailwind(letterSpacing) { if (!letterSpacing) return null; if (letterSpacing <= -0.05) return 'tracking-tighter'; if (letterSpacing <= -0.025) return 'tracking-tight'; if (letterSpacing >= -0.01 && letterSpacing <= 0.01) return 'tracking-normal'; if (letterSpacing >= 0.025) return 'tracking-wide'; if (letterSpacing >= 0.05) return 'tracking-wider'; if (letterSpacing >= 0.1) return 'tracking-widest'; return null; // Custom letter spacing } function textAlignToTailwind(textAlign) { if (!textAlign) return null; const align = textAlign.toLowerCase(); if (align.includes('left')) return 'text-left'; if (align.includes('center')) return 'text-center'; if (align.includes('right')) return 'text-right'; if (align.includes('justify')) return 'text-justify'; return null; } function generateImplementationGuide(node) { const componentType = guessComponentType(node); const tailwindClasses = generateTailwindClasses(node); const guide = { recommendedApproach: "", tailwindConfig: { customColors: [], customFontFamily: [], customSpacing: [] } }; // Generate recommendations based on component type switch (componentType) { case 'confirmation-dialog': guide.recommendedApproach = ` This appears to be a confirmation dialog component. Consider using: - Check if the host project already has a Dialog or Modal component - Use existing Button components for the actions - For the layout, use Tailwind flex utilities (${tailwindClasses.filter(c => c.startsWith('flex')).join(', ')}) - If the project uses React, consider using headlessui/Dialog or similar accessible components `; break; case 'button': guide.recommendedApproach = ` This appears to be a button component. Consider using: - Check if the host project already has a Button component with variants - For styling, use the Tailwind classes: ${tailwindClasses.join(' ')} - Match the existing button naming patterns in the project `; break; default: guide.recommendedApproach = ` This component can be implemented using the generated Tailwind classes. Look for similar components in the host project to maintain consistency. `; } // Add any custom color suggestions if (node.backgroundColor) { const { r, g, b } = node.backgroundColor; if (colorToTailwindClass(node.backgroundColor, 'bg') === null) { const hexColor = rgbToHex(r, g, b); guide.tailwindConfig.customColors.push({ name: suggestColorName(node.name, hexColor), value: hexColor }); } } // Add text style suggestions if (node.children) { const textNodes = findTextNodes(node); textNodes.forEach(textNode => { if (textNode.fills && textNode.fills.length > 0) { const fill = textNode.fills[0]; if (fill.type === 'SOLID' && colorToTailwindClass(fill.color, 'text') === null) { const { r, g, b } = fill.color; const hexColor = rgbToHex(r, g, b); guide.tailwindConfig.customColors.push({ name: suggestColorName(textNode.name + "-text", hexColor), value: hexColor }); } } }); } return guide; } // Helper function to find all text nodes in a component tree function findTextNodes(node, results = []) { if (!node) return results; if (node.type === 'TEXT') { results.push(node); } if (node.children && Array.isArray(node.children)) { node.children.forEach(child => findTextNodes(child, results)); } return results; } // Helper function to convert RGB to HEX function rgbToHex(r, g, b) { const toHex = (value) => { const hex = Math.round(value * 255).toString(16); return hex.length === 1 ? '0' + hex : hex; }; return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } // Helper function to suggest a color name based on component name and color value function suggestColorName(componentName, hexColor) { // Simplify the component name to create a base for the color name const baseName = componentName .toLowerCase() .replace(/[^a-z0-9]/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); // Check if it's a common color if (hexColor === '#000000') return 'black'; if (hexColor === '#ffffff') return 'white'; // For other colors, use component name and a suffix const r = parseInt(hexColor.substr(1, 2), 16); const g = parseInt(hexColor.substr(3, 2), 16); const b = parseInt(hexColor.substr(5, 2), 16); // Determine a color family let colorFamily = ''; if (r > g && r > b) colorFamily = 'red'; else if (g > r && g > b) colorFamily = 'green'; else if (b > r && b > g) colorFamily = 'blue'; else if (r === g && g === b) colorFamily = 'gray'; else if (r > 200 && g > 200 && b < 100) colorFamily = 'yellow'; else if (r > 200 && g < 100 && b > 200) colorFamily = 'purple'; else if (r < 100 && g > 200 && b > 200) colorFamily = 'cyan'; // Determine brightness for suffix const brightness = (r + g + b) / 3; let brightnessSuffix = ''; if (brightness < 85) brightnessSuffix = 'dark'; else if (brightness > 170) brightnessSuffix = 'light'; if (baseName.includes(colorFamily)) { return brightnessSuffix ? `${baseName}-${brightnessSuffix}` : baseName; } else { return brightnessSuffix ? `${baseName}-${colorFamily}-${brightnessSuffix}` : `${baseName}-${colorFamily}`; } } // Helper function to convert data to bullet points format function formatAsBulletPoints(data, indent = 0) { if (data === null || data === undefined) return ''; let result = ''; const indentStr = ' '.repeat(indent); if (Array.isArray(data)) { data.forEach((item, index) => { if (typeof item === 'object' && item !== null) { result += `${indentStr}- Item ${index + 1}:\n${formatAsBulletPoints(item, indent + 1)}`; } else { result += `${indentStr}- ${item}\n`; } }); } else if (typeof data === 'object' && data !== null) { const entries = Object.entries(data); entries.forEach(([key, value]) => { if (value === null || value === undefined) { result += `${indentStr}- ${key}: null\n`; } else if (Array.isArray(value) && value.length === 0) { result += `${indentStr}- ${key}: []\n`; } else if (typeof value === 'object' && Object.keys(value).length === 0) { result += `${indentStr}- ${key}: {}\n`; } else if (typeof value === 'object') { result += `${indentStr}- ${key}:\n${formatAsBulletPoints(value, indent + 1)}`; } else { result += `${indentStr}- ${key}: ${value}\n`; } }); } else { result += `${indentStr}${data}\n`; } return result; } // Helper to create a summary of the optimized data function createComponentSummary(data) { if (!data || !data.component) return ''; const component = data.component; let summary = ''; // Component basics summary += `# ${component.name} (${component.type})\n\n`; summary += `**Component Type:** ${component.componentType}\n`; summary += `**Component ID:** ${component.id}\n\n`; // Descriptive Component Structure with embedded information summary += `**Component Structure (Pseudo-HTML with Info):**\n\`\`\`html\n`; summary += `<!-- Main Component: ${component.name} (${component.type}) -->\n`; summary += `<div class="${component.tailwindClasses?.join(' ') || ''}" data-component-id="${component.id}">\n`; // Add descriptive children structure with embedded information summary += generateDescriptiveComponentStructure(component.children, 2); summary += `</div>\n\`\`\`\n\n`; // Tailwind classes - only include grouped by category for better organization if (component.tailwindClasses && component.tailwindClasses.length > 0) { const groupedClasses = groupTailwindClasses(component.tailwindClasses); summary += `**Tailwind Classes:**\n`; Object.entries(groupedClasses).forEach(([category, classes]) => { if (classes.length > 0) { summary += `- **${category}**: \`${classes.join(' ')}\`\n`; } }); summary += '\n'; } // Component State Variants if (component.variants && Object.keys(component.variants).length > 0) { summary += `**Component States:**\n`; Object.entries(component.variants).forEach(([key, value]) => { summary += `- **${key}**: ${value}\n`; }); summary += '\n'; } // Interactive States if (component.interactionPatterns && component.interactionPatterns.length > 0) { summary += `**Interactive States:**\n`; component.interactionPatterns.forEach(interaction => { summary += `- **${interaction.trigger || 'Unknown'}**: ${interaction.action || 'Unknown'}\n`; }); summary += '\n'; } // Enhanced Component Tree visualization if (component.children && component.children.length > 0) { summary += `**Component Tree:**\n\`\`\`\n`; summary += generateComponentTree(component); summary += `\`\`\`\n\n`; // Detailed children information with recursive traversal summary += `**Component Details (Full Structure):**\n`; summary += generateDetailedComponentTree(component.children, 0); summary += '\n'; } // Responsive Behavior summary += `**Responsive Behavior:**\n`; summary += `- Base behavior defined by Tailwind classes\n`; summary += `- Consider adding responsive variants (sm:, md:, lg:) for layout adjustments\n`; summary += `- For mobile and smaller screens, consider vertical stacking: \`sm:flex-col\`\n\n`; // Only include custom colors - they're directly useful if (component.implementationGuide && component.implementationGuide.tailwindConfig && component.implementationGuide.tailwindConfig.customColors && component.implementationGuide.tailwindConfig.customColors.length > 0) { summary += `**Custom Colors for Tailwind Config:**\n`; summary += `\`\`\`js\nmodule.exports = {\n theme: {\n extend: {\n colors: {\n`; // Group similar colors to reduce duplication const colorGroups = {}; component.implementationGuide.tailwindConfig.customColors.forEach(color => { // Strip common suffixes like "-text-blue" to group similar colors const baseName = color.name.replace(/-text-(blue|green|white|black)$/, ''); if (!colorGroups[baseName]) { colorGroups[baseName] = []; } colorGroups[baseName].push(color); }); // Output the first color of each group Object.entries(colorGroups).forEach(([baseName, colors]) => { if (colors.length > 0) { const color = colors[0]; summary += ` '${baseName}': '${color.value}',\n`; } }); summary += ` }\n }\n }\n};\n\`\`\`\n\n`; } // Full component data reference (commented out by default) summary += '\n<!-- Full component data is available in YAML or JSON format -->\n'; return summary; } // Function to display the full component tree in a visual format function generateComponentTree(component) { let result = ''; // Add root component result += `${component.name} (${component.type})\n`; // Add all children recursively if (component.children && component.children.length > 0) { result += generateComponentTreeChildren(component.children, 1); } return result; } // Helper for recursive tree generation function generateComponentTreeChildren(children, level) { if (!children || !Array.isArray(children)) return ''; let result = ''; const indent = '│ '.repeat(level); const lastIndex = children.length - 1; children.forEach((child, index) => { // Determine if this is the last child at this level const isLast = index === lastIndex; const prefix = isLast ? '└─ ' : '├─ '; const childLine = `${indent}${prefix}${child.name} (${child.type})`; result += childLine + '\n'; // Add children of this node recursively with adjusted indentation if (child.children && child.children.length > 0) { // If this is the last item, use space in the indentation for its children // Otherwise use the vertical bar to show the continuation const nextIndent = isLast ? level : level + 1; result += generateComponentTreeChildren(child.children, nextIndent); } }); return result; } // Function to generate detailed component information with a tree structure function generateDetailedComponentTree(children, level) { if (!children || !Array.isArray(children)) return ''; let result = ''; const indent = ' '.repeat(level); children.forEach(child => { result += `${indent}- **${child.name}** (${child.type})`; if (child.componentType && child.componentType !== 'unknown') { result += ` [${child.componentType}]`; } result += '\n'; // For text nodes, include complete content if (child.text) { result += `${indent} Text: "${child.text}"\n`; // Include text styling if available if (child.textStyle) { const textStyle = child.textStyle; result += `${indent} Style: `; if (textStyle.fontFamily) result += `${textStyle.fontFamily}, `; if (textStyle.fontSize) result += `${textStyle.fontSize}px, `; if (textStyle.fontWeight) result += `weight: ${textStyle.fontWeight}, `; if (textStyle.color) result += `color: ${textStyle.color}`; result += '\n'; } // Include tailwind text classes if (child.tailwindTextClasses && child.tailwindTextClasses.length > 0) { result += `${indent} Tailwind: \`${child.tailwindTextClasses.join(' ')}\`\n`; } } // Include child's tailwind classes if (child.tailwindClasses && child.tailwindClasses.length > 0) { result += `${indent} Tailwind: \`${child.tailwindClasses.join(' ')}\`\n`; } // For component instances, include properties if (child.type === 'INSTANCE' && child.componentProperties) { result += `${indent} Component Properties: ${JSON.stringify(child.componentProperties) .replace(/[{}"]/g, '') .replace(/,/g, ', ')}\n`; } // Recursively process children with increased indentation if (child.children && child.children.length > 0) { result += generateDetailedComponentTree(child.children, level + 2); } }); return result; } // Generate descriptive HTML-like structure with embedded Figma information function generateDescriptiveComponentStructure(children, indentLevel) { if (!children || children.length === 0) return ''; const indent = ' '.repeat(indentLevel); let structure = ''; children.forEach(child => { // Add common attributes to all elements const commonAttrs = [ `data-figma-id="${child.id || ''}"`, `data-type="${child.type || ''}"`, `data-name="${child.name || ''}"` ]; if (child.componentType && child.componentType !== 'unknown') { commonAttrs.push(`data-component-type="${child.componentType}"`); } if (child.type === 'TEXT') { // For text nodes, include text content and styling const textClasses = child.tailwindTextClasses?.join(' ') || ''; structure += `${indent}<!-- Text: ${child.name} -->\n`; structure += `${indent}<p class="${textClasses}" ${commonAttrs.join(' ')}`; // Add style information if available if (child.textStyle) { const { fontFamily, fontSize, fontWeight, color } = child.textStyle; structure += ` data-font="${fontFamily || 'default'}" data-size="${fontSize || ''}px" data-weight="${fontWeight || ''}"`; if (color) structure += ` data-color="${color}"`; } structure += `>\n`; structure += `${indent} ${child.text || '[No Text Content]'}\n`; structure += `${indent}</p>\n`; } else if (child.type === 'INSTANCE') { // For component instances, include component info structure += `${indent}<!-- Instance: ${child.name} -->\n`; structure += `${indent}<component`; // Add component ID and name if (child.componentId) { structure += ` data-component-id="${child.componentId}"`; } // Add Tailwind classes if (child.tailwindClasses && child.tailwindClasses.length > 0) { structure += ` class="${child.tailwindClasses.join(' ')}"`; } // Add component properties if (child.componentProperties) { structure += ` ${generateComponentAttributes(child.componentProperties)}`; } structure += ` ${commonAttrs.join(' ')}`; // Check if it has children if (child.children && child.children.length > 0) { structure += `>\n`; structure += generateDescriptiveComponentStructure(child.children, indentLevel + 2); structure += `${indent}</component>\n`; } else { structure += ` />\n`; } } else { // For container nodes, include styling and layout const divClasses = child.tailwindClasses?.join(' ') || ''; structure += `${indent}<!-- Container: ${child.name} -->\n`; // Add layout information if available let layoutInfo = ''; if (child.properties && child.properties.layout) { const { direction, gap, padding } = child.properties.layout; layoutInfo = ` data-layout="${direction || 'block'}" data-gap="${gap || 0}"`; if (padding && Object.keys(padding).length > 0) { layoutInfo += ` data-padding="${Object.entries(padding).map(([k, v]) => `${k}:${v}`).join(',')}"`; } } structure += `${indent}<div class="${divClasses}" ${commonAttrs.join(' ')}${layoutInfo}>\n`; // Recur