
Figma To React
- 4 installs
- 7 repo stars
- Updated December 25, 2025
- gbasin/figma-to-react
Converts Figma designs into pixel-perfect React components with Tailwind CSS through a status-driven, multi-step generation and validation loop.
About
A skill that turns Figma frames into React plus Tailwind components using the Figma MCP server, running a status-driven loop from setup through dimension and visual validation. A developer uses it to generate front-end screens from designs with dimension and pixel checks.
- Status-driven 8-step loop with pre-flight checks and TodoWrite tracking
- Recovery after compaction via status.sh inferring state from /tmp files
Figma To React by the numbers
- 4 all-time installs (skills.sh)
- Ranked #1,817 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/gbasin/figma-to-react --skill figma-to-reactAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 7 |
| Last updated | December 25, 2025 |
| Repository | gbasin/figma-to-react ↗ |
What it does
Converts Figma designs into pixel-perfect React components with Tailwind CSS through a status-driven, multi-step generation and validation loop.
Files
Figma to React
Convert Figma designs to pixel-perfect React components with Tailwind CSS.
Workflow
The workflow is a status-driven loop. Always check status before and after each step:
LOOP:
1. Run: $SKILL_DIR/scripts/status.sh
2. Read the step file for current_step (e.g., step-4b-validate-dimensions.md)
3. Execute that step's instructions
4. Go to step 1 (until step 8 complete)Step Reference
Create a TodoWrite list with these steps (Glob to find each file):
1. Setup - step-1-setup.md
2. Detect structure - step-2-detect-structure.md
3. Confirm config - step-3-confirm-config.md
3b. Create preview route - step-3b-preview-route.md
4. Generate screens (parallel) - step-4-generation.md
4b. Validate dimensions - step-4b-validate-dimensions.md
5. Import tokens - step-5-import-tokens.md
6. Validate screens (parallel) - step-6-validation.md
7. Rename assets - step-7-rename-assets.md
8. Disarm hook - step-8-disarm-hook.mdPre-flight Checks
Each step file has a pre-flight check. If status.sh says you're on step 4b but you're trying to execute step 5: 1. STOP - don't execute step 5 2. Update TodoWrite to uncheck wrongly-completed steps 3. Read the correct step file (step 4b)
This prevents skipping steps, which was a common failure mode.
Recovery After Compaction
If context is compacted, run $SKILL_DIR/scripts/status.sh to see exactly where you are. The script infers state from /tmp files - no context needed.
State files used by status.sh:
/tmp/figma-to-react/capture-active- Skill is active/tmp/figma-to-react/config.json- Config with screens/screenNames mapping/tmp/figma-to-react/steps/4b/*.json- Dimension validation results/tmp/figma-to-react/steps/4b/user-decisions.json- User decisions on missing dims/tmp/figma-to-react/steps/5/complete.json- Token import done/tmp/figma-to-react/validation/*/result.json- Visual validation results/tmp/figma-to-react/steps/7/complete.json- Asset rename done
{
"name": "figma-to-react-skill",
"private": true,
"type": "module",
"dependencies": {
"playwright": "^1.57.0"
}
}
Step 1: Setup
Prepare the environment and install required tools.
Install Tools
# Bun for fast TypeScript execution, ImageMagick for visual comparison
command -v bun >/dev/null || brew install oven-sh/bun/bun
brew install imagemagick
# Playwright for screenshot capture, oxlint for fast linting
pnpm add -D playwright oxlint && bunx playwright install chromiumArm the Capture Hook
# Clean up any previous run
rm -rf /tmp/figma-to-react
# Arm the hook
mkdir -p /tmp/figma-to-react/captures
touch /tmp/figma-to-react/capture-activeWhat This Does
Tool installations:
- Bun runs TypeScript scripts directly (faster than tsx/ts-node)
- ImageMagick provides the
magickCLI for image comparison (used in step 6) - Playwright enables headless screenshot capture of rendered components
- oxlint for fast linting of generated components
Capture hook: The PostToolUse hook watches for Figma MCP calls. When the marker file exists:
- Captures full response to
/tmp/figma-to-react/captures/figma-{nodeId}.txt - Suppresses raw output from Claude's context (saves ~50KB per screen)
- Shows brief confirmation: "Captured to figma-{nodeId}.txt"
Without the marker file, Figma MCP works normally (useful for debugging).
Recovery files (created in step 3):
input.txt- Raw Figma links from user (one per line)config.json- Confirmed output paths
These enable recovery if context is compacted during a multi-screen job.
Next Step
Mark this step complete. Read step-2-detect-structure.md.
Step 2: Detect Project Structure
Scan the codebase to detect framework and conventions.
Actions
# Check package.json for framework
cat package.json | grep -E '"(react|next|vite|@vitejs)"'
# Find existing component directories
ls -d src/components/ components/ app/components/ 2>/dev/null
# Find existing style directories
ls -d src/styles/ styles/ src/css/ 2>/dev/null
# Find public/static asset directories
ls -d public/ static/ public/assets/ 2>/dev/nullWhat to Look For
- Framework: Vite, Next.js, Create React App
- Component location: Where existing components live
- Styles location: Where CSS/tokens should go
- Assets location: Where static files are served from
Output
Prepare a configuration summary for user confirmation:
- Components directory
- Assets directory
- Tokens file path
- URL prefix for assets
Next Step
Mark this step complete. Read step-3-confirm-config.md.
Step 3: Confirm Configuration
Use hardcoded defaults with quick override option.
Default Paths
Components: src/components/figma/
Assets: public/figma-assets/
Tokens: src/styles/figma-tokens.css
URL prefix: /figma-assetsPresent to User
Detected: [Framework] + React + Tailwind
Output paths:
Components: src/components/figma/
Assets: public/figma-assets/
Tokens: src/styles/figma-tokens.css
Proceed with defaults? [Y/edit]If user types "edit", use AskUserQuestion to gather custom paths.
Parse Figma URL
Extract from user's Figma link:
https://www.figma.com/design/{fileKey}/{fileName}?node-id={nodeId}Get list of nodeIds to process (may be multiple screens).
Write Recovery Files
Save state for compaction resilience. If context is compacted mid-job, agent can re-read these files to recover.
1. Save raw user input (use actual links/IDs from user, not these examples):
cat > /tmp/figma-to-react/input.txt << 'EOF'
https://www.figma.com/design/abc123/MyFile?node-id=237-2571
https://www.figma.com/design/abc123/MyFile?node-id=237-2572
EOFStore exactly what the user provided (links or node IDs), one per line.
2. Save confirmed config (use actual values from current job, not these examples):
# EXAMPLE ONLY - replace with actual values:
cat > /tmp/figma-to-react/config.json << 'EOF'
{
"componentDir": "src/components/figma",
"assetDir": "public/figma-assets",
"tokensFile": "src/styles/figma-tokens.css",
"urlPrefix": "/figma-assets",
"screens": ["237:2571", "237:2572"],
"screenNames": {
"237:2571": "LoginScreen",
"237:2572": "HomeScreen"
}
}
EOFIMPORTANT: The screens and screenNames fields are required for status.sh to track parallel progress.
screens: Array of all nodeIds from the user's Figma linksscreenNames: Map from nodeId to component name you'll generate
Next Step
Mark this step complete. Read step-3b-preview-route.md.
Step 3b: Create Preview Route
Create the preview infrastructure BEFORE component generation so users can watch components appear in real-time.
Why Before Generation
- Preview route with dynamic imports auto-discovers new components
- User sees components appear as step 4 generates them
- During step 4b (dimension validation), user can see the preview while deciding
Key Principle: Standalone Preview
The preview must render components in isolation, without inheriting any layout chrome (headers, navbars) from the user's app. This ensures pixel-perfect screenshot validation.
Framework Detection
From step 2, determine:
- Vite/React Router → separate HTML entry point
- Next.js → pages/ directory (bypasses App Router layouts)
Vite/React Router Implementation
Use a separate HTML entry point to avoid inheriting any App.tsx layout:
1. Copy templates to project:
cp $SKILL_DIR/templates/figma-preview.html figma-preview.html
cp $SKILL_DIR/templates/figma-preview-entry.vite.tsx src/pages/figma-preview-entry.tsx2. Adjust the glob path if components aren't in src/components/figma/ (see "Adjust Glob/Import Path" below)
Why separate entry? This bypasses the main App.tsx entirely. No routes to configure, no layout inheritance.
Template locations:
$SKILL_DIR/templates/figma-preview.html$SKILL_DIR/templates/figma-preview-entry.vite.tsx
Next.js Implementation
Use the pages/ directory to bypass App Router layouts entirely:
1. Copy templates to project:
mkdir -p pages app/api/figma-screens
cp $SKILL_DIR/templates/figma-preview.nextjs-pages.tsx pages/figma-preview.tsx
cp $SKILL_DIR/templates/figma-screens-api.nextjs.ts app/api/figma-screens/route.ts2. Update the CSS import path in pages/figma-preview.tsx if needed:
import '../app/globals.css'; // Adjust to your global CSS location3. Access via: http://localhost:3000/figma-preview?screen=ComponentName
Why pages/ directory? Even in App Router projects, pages in pages/ don't inherit app/layout.tsx. No changes to existing app structure needed.
Template locations:
$SKILL_DIR/templates/figma-preview.nextjs-pages.tsx$SKILL_DIR/templates/figma-screens-api.nextjs.ts
Adjust Glob/Import Path If Needed
If componentDir in config differs from default (src/components/figma), update the import pattern:
Vite:
// Default
const modules = import.meta.glob('../components/figma/*.tsx');
// Custom location
const modules = import.meta.glob('../../path/to/figma/*.tsx');Next.js:
// Default
import(`@/components/figma/${screenName}`)
// Custom location - update the path accordinglyStart Dev Server (Required)
IMPORTANT: The preview won't work without a running dev server.
1. Start the dev server:
pnpm dev
# or: npm run dev2. Wait for startup and note the port from output (e.g., localhost:5173 for Vite, localhost:3000 for Next.js)
3. Verify the preview works by curling the URL:
# Vite
curl -s http://localhost:5173/figma-preview.html | grep "figma-preview-root"
# Next.js
curl -s http://localhost:3000/figma-preview | grep "Figma Preview"If this returns nothing, the server isn't serving the preview correctly.
Tell the User
After verifying the server:
Vite:
Preview created at /figma-preview.html
Dev server running at http://localhost:[port]
As components are generated, they will automatically appear in the preview.
Open http://localhost:[port]/figma-preview.html to watch progress.Next.js:
Preview route created at /figma-preview
Dev server running at http://localhost:[port]
As components are generated, they will automatically appear in the preview.
Open http://localhost:[port]/figma-preview to watch progress.Next Step
Mark this step complete. Read step-4-generation.md.
Step 4: Generate Screens (Parallel)
Spawn sub-agents to process each screen. Can run in parallel.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 4If this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Preview Available: The preview was created in step 3b. As components are
generated, they automatically appear in the preview. Tell the user they can watch
progress there (Vite:/figma-preview.html?screen=..., Next.js:/figma-preview?screen=...).
For Each Screen
Spawn a sub-agent using Task tool:
Task(
subagent_type: "general-purpose",
prompt: """
Process Figma screen for the figma-to-react skill.
INPUTS:
- fileKey: "{fileKey}"
- nodeId: "{nodeId}"
- componentName: "{ComponentName}"
- componentPath: "{componentDir}/{ComponentName}.tsx"
- assetDir: "{assetDir}"
- urlPrefix: "{urlPrefix}"
- tokensFile: "{tokensFile}"
- SKILL_DIR: "{skillDir}"
STEPS:
1. Call the Figma MCP get_metadata tool to get frame dimensions.
Use whichever server is available:
- mcp__figma__get_metadata (direct MCP - uses fileKey)
- mcp__plugin_figma_figma__get_metadata (marketplace plugin - uses fileKey)
- mcp__plugin_figma_figma-desktop__get_metadata (desktop plugin - uses active tab)
Parameters:
fileKey: "{fileKey}" (not needed for desktop)
nodeId: "{nodeId}"
A hook automatically extracts dimensions and saves them to
/tmp/figma-to-react/metadata/{nodeId}.json.
2. Call the Figma MCP get_design_context tool.
Use whichever server is available:
- mcp__figma__get_design_context (direct MCP - uses fileKey)
- mcp__plugin_figma_figma__get_design_context (marketplace plugin - uses fileKey)
- mcp__plugin_figma_figma-desktop__get_design_context (desktop plugin - uses active tab)
Parameters:
fileKey: "{fileKey}" (web only)
nodeId: "{nodeId}"
clientFrameworks: "react"
clientLanguages: "typescript"
The hook will capture response to /tmp/figma-to-react/captures/figma-{nodeId}.txt
3. Run the processing script:
$SKILL_DIR/scripts/process-figma.sh \\
/tmp/figma-to-react/captures/figma-{nodeId}.txt \\
{componentPath} \\
{assetDir} \\
{urlPrefix} \\
{tokensFile}
4. Link component name to metadata:
$SKILL_DIR/scripts/save-component-metadata.sh \\
"{ComponentName}" "{nodeId}" "{componentPath}"
This adds the component name to the dimensions already saved by the hook.
5. Lint and auto-fix Tailwind issues:
bun oxlint --fix {componentPath}
This auto-fixes ~90% of MCP output issues:
- Class ordering
- Unnecessary arbitrary values (translate-x-[-50%] → -translate-x-1/2)
- Redundant classes (filter in TW v3)
- Shorthand opportunities (top-X bottom-X → inset-y-X)
6. Check for remaining issues:
bun oxlint {componentPath}
Fix these manually based on the component's intent.
7. Return summary: component path, asset count, oxlint fixes applied, any errors.
(Dimensions are already in the metadata file.)
"""
)Why Sub-Agents
- Each Figma MCP response is ~50KB
- Sub-agents keep this isolated from parent context
- Multiple screens can run in parallel
- Parent only sees summaries
Collect Results
Track for each screen:
- Component file path
- Figma nodeId (needed for validation)
- Success/failure status
Dimensions are stored in /tmp/figma-to-react/metadata/{ComponentName}.json for step 6.
Next Step
Mark this step complete. Read step-4b-validate-dimensions.md.
Step 4b: Validate Dimension Coverage
Check that all collapse-prone elements have dimensions in the metadata. Prompt user for any missing values.
Note: All code blocks in this document are examples only. Use the actual node IDs, file paths, and element names from the current conversion at runtime.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 4bIf this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Why This Step
The MCP metadata may not include dimensions for all node IDs in the generated TSX, especially:
- Nested component instances (IDs like
I237:2572;2708:1961) - Elements from component libraries
- Deeply nested frames
Without dimensions, fix-collapsed-containers.sh can't fix elements that collapse due to absolute-positioned children.
Multi-Screen: Detect Shared Components First
When converting 2+ screens, run shared component detection BEFORE asking about any dimensions:
bun $SKILL_DIR/scripts/find-shared-components.ts \
/tmp/figma-to-react/captures/figma-*.txtOutput example:
{
"shared": [
{
"definitionId": "2708:1961",
"name": "exit button",
"instances": [
{"instanceId": "I237:2417;2708:1961", "screen": "237:2416"},
{"instanceId": "I237:2572;2708:1961", "screen": "237:2571"}
]
}
],
"total_shared": 15
}Save this output. When handling missing dimensions below:
- Shared components → ask ONCE, apply to ALL instances across ALL screen JSONs
- Screen-specific → ask and apply per-screen as normal
To check if a missing element is shared: 1. Extract definition ID from instance ID (last segment after ;) 2. Check if that definition ID is in the shared list
Skip this section for single-screen conversions.
Use the Preview
The preview (step 3b) is available at:
- Vite:
/figma-preview.html?screen={ComponentName} - Next.js:
/figma-preview?screen={ComponentName}
When asking the user about missing dimensions, remind them they can check the preview to see how elements currently render before deciding on dimensions.
For Each Generated Screen
Run the validation script and save output for status.sh tracking:
mkdir -p /tmp/figma-to-react/steps/4b
# For each screen, save validation output
$SKILL_DIR/scripts/validate-dimensions-coverage.sh \
/tmp/figma-to-react/captures/figma-{nodeId}.txt \
/tmp/figma-to-react/metadata/{nodeId}-dimensions.json \
> /tmp/figma-to-react/steps/4b/{nodeId}.jsonReplace {nodeId} with the actual node ID (e.g., 237-2571).
IMPORTANT: The saved JSON is used by status.sh to track partial completion.
Batch Processing (Multi-Screen)
For multi-screen conversions, validate all files at once using directory mode:
# Validate all captures against all metadata files
$SKILL_DIR/scripts/validate-dimensions-coverage.sh \
/tmp/figma-to-react/captures/ \
/tmp/figma-to-react/metadata/Or specify multiple files explicitly:
# Validate specific screens
$SKILL_DIR/scripts/validate-dimensions-coverage.sh \
/tmp/figma-to-react/captures/figma-237-2571.txt \
/tmp/figma-to-react/captures/figma-237-2416.txt \
/tmp/figma-to-react/metadata/The script auto-matches capture files to dimension files by node ID (e.g., figma-237-2571.txt → 237-2571-dimensions.json).
The script outputs JSON with any missing dimensions (example output):
{
"missing": [
{"id": "I237:2572;2708:1961;2026:14620", "name": "padding"},
{"id": "I237:2583;2603:5165", "name": "footer"}
],
"critical_missing": 2
}If Missing Dimensions Found
1. Show the user what's missing with friendly names (example format):
Found 3 elements that may need dimensions:
1. padding (exit button) - I237:2572;2708:1961;2026:14620
2. padding (back button) - I237:2572;2708:1962;2026:14620
3. footer - I237:2583;2603:51652. Ask about EACH element separately using AskUserQuestion with multiple questions. Build the questions array from the actual missing elements. Example structure:
AskUserQuestion(questions: [
{
question: "Dimensions for 'padding' in exit button?",
header: "Exit btn",
options: [
{label: "48 x 48", description: "Square button"},
{label: "Skip", description: "Don't fix"}
]
},
{
question: "Dimensions for 'padding' in back button?",
header: "Back btn",
options: [
{label: "48 x 48", description: "Square button"},
{label: "Skip", description: "Don't fix"}
]
},
{
question: "Dimensions for 'footer'?",
header: "Footer",
options: [
{label: "Skip", description: "Likely doesn't need fixing"},
{label: "393 x 32", description: "Full width footer"}
]
}
])Tips for options:
- Suggest common sizes based on element name (buttons often 48x48, footers often full-width)
- Always include "Skip" option
- Include "Let me check Figma" if unsure what to suggest
3. Save decisions to disk before applying - see Save User Decisions below.
4. For each non-skipped dimension, run (substitute actual values):
$SKILL_DIR/scripts/add-missing-dimensions.sh \
/tmp/figma-to-react/metadata/{nodeId}-dimensions.json \
"{element-id}" {width} {height}Replace {nodeId}, {element-id}, {width}, and {height} with the actual values.
Batch Adding Dimensions
Multiple IDs to single file (same dimensions):
# Add 393x48 to multiple button IDs in one file
$SKILL_DIR/scripts/add-missing-dimensions.sh \
/tmp/figma-to-react/metadata/2006-2030-dimensions.json \
393 48 \
"I2006:2037;2189:5232" "I2006:2037;2190:6255" "I2006:2037;2189:5226;661:724"Multiple files (same dimensions):
# Add 393x48 to IDs across multiple files
$SKILL_DIR/scripts/add-missing-dimensions.sh 393 48 \
--file /tmp/figma-to-react/metadata/2006-2062-dimensions.json "I2006:2073;2603:5160" "I2006:2073;2603:5161" \
--file /tmp/figma-to-react/metadata/2006-2075-dimensions.json "I2006:2086;2603:5160" "I2006:2086;2603:5161" \
--file /tmp/figma-to-react/metadata/237-2416-dimensions.json "I237:2428;2603:5160"For shared components (multi-screen): Apply to ALL instances across ALL screen JSONs using batch mode:
# If "exit button" (definition 2708:1961) is 48x48, apply to both screens in one call:
$SKILL_DIR/scripts/add-missing-dimensions.sh 48 48 \
--file /tmp/figma-to-react/metadata/237-2416-dimensions.json "I237:2417;2708:1961" \
--file /tmp/figma-to-react/metadata/237-2571-dimensions.json "I237:2572;2708:1961"Note: Manually-added dimensions get a manual: true flag in the JSON. This tells fix-collapsed-containers.sh to aggressively replace relative sizing classes (h-full, w-full, h-auto, w-auto, h-fit, w-fit) with explicit pixel values.
Dimensions from Figma MCP (without the flag) use conservative behavior that preserves relative sizing classes, trusting the original design intent.
5. Re-run fix-collapsed-containers.sh if any dimensions were added (substitute actual paths):
$SKILL_DIR/scripts/fix-collapsed-containers.sh \
{componentPath} \
/tmp/figma-to-react/metadata/{nodeId}-dimensions.json \
> {componentPath}.tmp && mv {componentPath}.tmp {componentPath}Replace {componentPath} and {nodeId} with the actual values from the current conversion.
Batch Fixing Collapsed Containers
Directory mode (auto-match files by node ID):
# Process all tsx files in output/ with matching dimensions in metadata/
$SKILL_DIR/scripts/fix-collapsed-containers.sh \
/path/to/output/ \
/tmp/figma-to-react/metadata/Files are matched by finding the node ID in the tsx content (e.g., PersonaScreen1.tsx with data-node-id="2006:2038" matches 2006-2038-dimensions.json).
Multiple pairs mode:
# Fix multiple specific file pairs
$SKILL_DIR/scripts/fix-collapsed-containers.sh \
--pair /path/to/PersonaScreen1.tsx /tmp/figma-to-react/metadata/2006-2038-dimensions.json \
--pair /path/to/PersonaScreen2.tsx /tmp/figma-to-react/metadata/2006-2062-dimensions.json \
--pair /path/to/PersonaScreen3.tsx /tmp/figma-to-react/metadata/2006-2075-dimensions.jsonBatch modes fix files in-place (no stdout redirection needed).
Skip Conditions
- If
critical_missingis 0, no user interaction needed - Some elements may render correctly despite being flagged (false positives)
Many Missing Dimensions (>5)
If many elements are flagged, still ask the user - don't decide for them:
AskUserQuestion(questions: [
{
question: "21 collapse-prone elements found. How to proceed?",
header: "Dimensions",
options: [
{label: "Skip all", description: "Proceed to visual validation - fix issues there if any"},
{label: "Show me the list", description: "I'll pick which ones to fix"},
{label: "Fix common patterns", description: "Auto-fix buttons (48x48), icons (24x24)"}
]
}
])Never skip silently. The user should always make the call.
Save User Decisions
Save decisions to disk before applying them. This protects against compaction—if context is summarized between asking and applying, specific dimension values would be lost. Since add-missing-dimensions.sh is idempotent, recovery just means re-applying all non-skip decisions from the saved file.
# EXAMPLE STRUCTURE - use actual IDs and decisions from current job:
cat > /tmp/figma-to-react/steps/4b/user-decisions.json << EOF
{
"timestamp": "$(date -Iseconds)",
"total_missing": ${TOTAL_MISSING},
"addressed_ids": [
"${ID_1}",
"${ID_2}",
"${ID_3}"
],
"decisions": [
{"id": "${ID_1}", "action": "48x48", "width": 48, "height": 48},
{"id": "${ID_2}", "action": "skip"},
{"id": "${ID_3}", "action": "48x48", "width": 48, "height": 48}
]
}
EOFReplace ${...} placeholders with actual values from the validation output and user responses.
addressed_ids must include ALL IDs from ALL validation JSONs:
- If 10 missing dimensions were found across all screens, all 10 must appear
- If you only ask about some, status.sh will detect incomplete and bounce you back
- "skip" counts as addressed
Partial Completion / Recovery
If resuming after compaction, check user-decisions.json first. If it exists, re-apply all non-skip decisions (idempotent). If not, re-ask the user.
If status.sh says you're still on step 4b: 1. Read next_action field for remaining count 2. Diff validation JSONs against addressed_ids to find remaining IDs 3. Ask user about remaining, append to user-decisions.json
Next Step
Mark this step complete. Read step-5-import-tokens.md.
Step 5: Import Tokens CSS
One-time setup to import generated design tokens.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 5If this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Check If Already Imported
Look for existing import in main CSS file:
grep -l "figma-tokens.css" src/index.css src/App.css src/styles/*.css 2>/dev/nullIf Not Imported
Add to main CSS file (e.g., src/index.css):
@import "./styles/figma-tokens.css";
@tailwind base;
@tailwind components;
@tailwind utilities;The import must come BEFORE Tailwind directives.
Why This Matters
Figma MCP outputs code like:
className="bg-[var(--background\/overlay,rgba(0,0,0,0.8))]"The CSS variables need to be defined for Tailwind to parse these correctly. The tokens file provides:
:root {
--background\/overlay: rgba(0,0,0,0.8);
}Mark Complete
After importing (or if already imported), save completion marker:
mkdir -p /tmp/figma-to-react/steps/5
echo '{"complete": true}' > /tmp/figma-to-react/steps/5/complete.jsonNext Step
Read step-6-validation.md.
Step 6: Validate Screens
Compare rendered components to Figma screenshots. Fix until visual diff ≤ 5%.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 6If this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Prerequisites
- Dev server running (started in step 3b)
- Preview created (step 3b)
- Figma screenshots captured
- Bun installed (step 1)
Preview URL Format
The preview URL depends on framework (from step 3b):
- Vite:
http://localhost:{port}/figma-preview.html?screen={ComponentName} - Next.js:
http://localhost:{port}/figma-preview?screen={ComponentName}
Script: validate-component.sh
Runs one validation pass. Captures screenshot, compares, returns status.
$SKILL_DIR/scripts/validate-component.sh \
<component> <figma-png> <preview-url> <component-path> [prev-diff]Exit codes:
| Code | Status | Action |
|---|---|---|
| 0 | success | Done - diff ≤ 5% |
| 1 | needs_fix | Make ONE fix, re-run with new diff as prev-diff |
| 2 | good_enough | Done - diff ≤ 1% |
| 5 | max_passes | Done - 10 passes reached, accept current state |
| 6 | no_improvement | Change was reverted, try a DIFFERENT fix |
Output (JSON):
{
"status": "needs_fix",
"pass": 2,
"diff": 8.45,
"prev_diff": 12.30,
"diff_image": "/tmp/.../pass-2/diff.png",
"message": "Pass 2: 8.45% (improved 3.85% from 12.30%)"
}Validation Loop
For each screen, spawn a sub-agent:
Task(
subagent_type: "general-purpose",
prompt: """
Validate component until done.
INPUTS:
- component: "{ComponentName}"
- componentPath: "{componentPath}"
- figmaPng: "/tmp/figma-to-react/screenshots/figma-{nodeId}.png"
- previewUrl: "{previewUrl}" (see Preview URL Format above)
- SKILL_DIR: "{skillDir}"
NOTE: previewUrl format depends on framework - see Preview URL Format section
Track current diff across iterations.
LOOP:
1. RUN VALIDATION
result=$($SKILL_DIR/scripts/validate-component.sh \
"{ComponentName}" "{figmaPng}" "{previewUrl}" "{componentPath}" $PREV_DIFF)
Parse JSON output. Note the exit code.
2. CHECK STATUS
- Exit 0 or 2: DONE - report success
- Exit 5: DONE - max passes reached, report final state
- Exit 1: Continue to step 3 (fix needed)
- Exit 6: Continue to step 3 (try different fix, change was reverted)
3. FIX
- Read diff_image from output
- Bright areas = differences
- Make ONE targeted fix to {componentPath}
- If exit was 6: your last fix didn't help, try something DIFFERENT
- Update PREV_DIFF to current diff
- Go to step 1
RETURN: final status, diff %, fixes made
"""
)Run in Parallel
Spawn all validation sub-agents simultaneously. Each uses its own preview URL.
Save Results
Each validation must save its final result for status.sh tracking:
# The validate-component.sh script outputs JSON. Save it:
mkdir -p /tmp/figma-to-react/validation/{ComponentName}
$SKILL_DIR/scripts/validate-component.sh \
"{ComponentName}" "{figmaPng}" "{previewUrl}" "{componentPath}" $PREV_DIFF \
| tee /tmp/figma-to-react/validation/{ComponentName}/result.jsonOr let the sub-agent redirect stdout to result.json after the final pass.
Note: status.sh checks for result.json with status = success, good_enough, or max_passes to determine completion.
Next Step
Read step-7-rename-assets.md.
Step 7: Rename Generic Assets (Optional)
Offer to rename assets with generic names to meaningful ones.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 7If this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Check for Generic Names
Look for assets like:
asset.svg,asset-1.svg,asset-abc123.svgimage.png,image-1.png
ls {assetDir}/*.{svg,png,jpg} 2>/dev/null | grep -E '(asset|image)[-0-9]*\.'If Generic Assets Found
Present to user:
Found 12 assets with generic names. Analyze and rename?
Current -> Suggested:
asset.svg -> close-icon.svg (X shape, likely close button)
asset-1.svg -> back-arrow.svg (left-pointing arrow)
image.png -> face-capture-bg.png (blurred face photo)
Apply renames? [Y/n/select]Renaming Process
Use the rename-assets.sh script:
# Single component
$SKILL_DIR/scripts/rename-assets.sh \
/tmp/figma-to-react/captures/figma-{nodeId}.txt \
{assetDir} \
{componentPath}
# Directory of components (finds all .tsx files)
$SKILL_DIR/scripts/rename-assets.sh \
/tmp/figma-to-react/captures/figma-{nodeId}.txt \
{assetDir} \
src/components/
# Multiple component files
$SKILL_DIR/scripts/rename-assets.sh \
/tmp/figma-to-react/captures/figma-{nodeId}.txt \
{assetDir} \
src/A.tsx src/B.tsxThe script has two phases: 1. Rename: Parses MCP output for component descriptions, renames asset-*.svg to meaningful names 2. Dedup: Merges identical assets (normalizes SVG ids before comparing), keeps shortest/best name
Mark Complete
After renaming (or if no generic assets found), save completion marker:
mkdir -p /tmp/figma-to-react/steps/7
echo '{"complete": true}' > /tmp/figma-to-react/steps/7/complete.jsonNext Step
Read step-8-disarm-hook.md.
Step 8: Disarm Hook and Finalize
Clean up and verify the workflow completed successfully.
Pre-flight Check
$SKILL_DIR/scripts/status.sh --check 8If this fails, it prints the correct step. Uncheck wrongly-completed TodoWrite items and read that step file instead.
Disarm the Hook
rm /tmp/figma-to-react/capture-activeThis returns Figma MCP to normal operation (output shown, not suppressed).
Verify Results
1. Components generated: Check that all .tsx files exist 2. Tokens imported: Verify CSS import is in main stylesheet 3. Assets downloaded: Check asset directory has files 4. Dev server works: Components render without errors
Summary to User
Figma to React complete!
Generated:
- {N} components in {componentDir}/
- {M} assets in {assetDir}/
- Design tokens in {tokensFile}
Next steps:
1. Add interactivity (onClick, useState, etc.)
2. Import fonts used in Figma designs
3. Connect to your app's routingCleanup
Remove temporary files:
rm -rf /tmp/figma-to-reactSkill Complete
All steps done. Mark final todo as complete.
#!/usr/bin/env bash
#
# add-missing-dimensions.sh
#
# Adds dimension entries to dimensions JSON files with manual: true flag.
#
# Usage modes:
# # Single ID (backward compatible)
# ./add-missing-dimensions.sh <json> <node-id> <width> <height>
#
# # Multiple IDs to single file (same dimensions)
# ./add-missing-dimensions.sh <json> <width> <height> <id1> [id2] [id3]...
#
# # Multiple files (same dimensions)
# ./add-missing-dimensions.sh <width> <height> --file <json1> <id1> [id2]... [--file <json2> <id3>...]
#
# Examples:
# # Single ID
# ./add-missing-dimensions.sh dims.json "232:1470" 393 64
#
# # Multiple IDs to single file
# ./add-missing-dimensions.sh dims.json 393 48 "I2006:2073;2603:5160" "I2006:2073;2603:5161"
#
# # Multiple files
# ./add-missing-dimensions.sh 393 48 \
# --file metadata/2006-2062-dimensions.json "I2006:2073;2603:5160" "I2006:2073;2603:5161" \
# --file metadata/2006-2075-dimensions.json "I2006:2086;2603:5160"
set -e
if ! command -v jq &>/dev/null; then
echo "Error: jq is required" >&2
exit 1
fi
# Helper: add single dimension to JSON file
add_dimension() {
local json_file="$1"
local node_id="$2"
local width="$3"
local height="$4"
if [ ! -f "$json_file" ]; then
echo "Error: Dimensions JSON not found: $json_file" >&2
return 1
fi
local temp_file=$(mktemp)
jq --arg id "$node_id" --argjson w "$width" --argjson h "$height" \
'. + {($id): {"w": $w, "h": $h, "manual": true}}' "$json_file" > "$temp_file"
mv "$temp_file" "$json_file"
echo "✓ Added $node_id: ${width}x${height} to $(basename "$json_file")" >&2
}
# Helper: check if string is a positive integer
is_number() {
[[ "$1" =~ ^[0-9]+$ ]]
}
# Detect mode based on arguments
if [ $# -lt 4 ]; then
echo "Usage:" >&2
echo " $0 <json> <node-id> <width> <height> # Single ID" >&2
echo " $0 <json> <width> <height> <id1> [id2]... # Multiple IDs" >&2
echo " $0 <width> <height> --file <json> <ids>... # Multiple files" >&2
exit 1
fi
# Mode 3: Multiple files (first arg is width)
if is_number "$1" && is_number "$2" && [ "$3" = "--file" ]; then
WIDTH="$1"
HEIGHT="$2"
shift 2 # Remove width and height
CURRENT_JSON=""
ADDED=0
while [ $# -gt 0 ]; do
if [ "$1" = "--file" ]; then
shift
if [ $# -eq 0 ]; then
echo "Error: --file requires a filename" >&2
exit 1
fi
CURRENT_JSON="$1"
shift
elif [ -n "$CURRENT_JSON" ]; then
add_dimension "$CURRENT_JSON" "$1" "$WIDTH" "$HEIGHT"
ADDED=$((ADDED + 1))
shift
else
echo "Error: Node ID '$1' specified before --file" >&2
exit 1
fi
done
echo "✓ Added $ADDED dimensions (${WIDTH}x${HEIGHT})" >&2
exit 0
fi
# Mode 2: Multiple IDs to single file (json, width, height, id1, id2...)
# Detect: 2nd and 3rd args are numbers, 4th+ are node IDs
if [ -f "$1" ] && is_number "$2" && is_number "$3" && [ $# -ge 4 ]; then
DIMENSIONS_JSON="$1"
WIDTH="$2"
HEIGHT="$3"
shift 3 # Remove json, width, height
ADDED=0
for node_id in "$@"; do
add_dimension "$DIMENSIONS_JSON" "$node_id" "$WIDTH" "$HEIGHT"
ADDED=$((ADDED + 1))
done
echo "✓ Added $ADDED dimensions (${WIDTH}x${HEIGHT}) to $(basename "$DIMENSIONS_JSON")" >&2
exit 0
fi
# Mode 1: Single ID (original backward-compatible mode)
# Format: <json> <id> <width> <height>
DIMENSIONS_JSON="$1"
NODE_ID="$2"
WIDTH="$3"
HEIGHT="$4"
if [ -z "$DIMENSIONS_JSON" ] || [ -z "$NODE_ID" ] || [ -z "$WIDTH" ] || [ -z "$HEIGHT" ]; then
echo "Usage: $0 <dimensions-json> <node-id> <width> <height>" >&2
exit 1
fi
if [ ! -f "$DIMENSIONS_JSON" ]; then
echo "Error: Dimensions JSON not found: $DIMENSIONS_JSON" >&2
exit 1
fi
if ! is_number "$WIDTH" || ! is_number "$HEIGHT"; then
echo "Error: Width and height must be positive integers" >&2
exit 1
fi
add_dimension "$DIMENSIONS_JSON" "$NODE_ID" "$WIDTH" "$HEIGHT"
#!/usr/bin/env bash
#
# capture-figma-metadata.sh
#
# PostToolUse hook for Figma MCP get_metadata.
# Extracts frame dimensions from XML response and saves to component-metadata.json.
#
# Input (stdin): JSON with tool_input and tool_result
# Output: /tmp/figma-to-react/metadata/{nodeId}.json
#
set -e
# Read JSON from stdin
INPUT=$(cat)
# Extract nodeId from tool_input
NODE_ID=$(echo "$INPUT" | jq -r '.tool_input.nodeId // empty')
if [ -z "$NODE_ID" ]; then
echo "Warning: No nodeId in get_metadata call" >&2
echo '{}'
exit 0
fi
# Extract the XML content from tool_response (not tool_result!)
# The response can be a string or array of content blocks
XML_CONTENT=$(echo "$INPUT" | jq -r '
.tool_response as $resp |
if ($resp | type) == "array" then
$resp[0].text // empty
elif ($resp | type) == "string" then
$resp
else
empty
end
' 2>/dev/null)
if [ -z "$XML_CONTENT" ]; then
echo "Warning: No XML content in get_metadata response" >&2
echo '{}'
exit 0
fi
# Extract width and height from the root frame element
# Look for patterns like width="390" height="844" or size="390x844"
# The XML format varies, so try multiple patterns
# Try width="X" height="Y" pattern (common in Figma XML)
WIDTH=$(echo "$XML_CONTENT" | grep -oE 'width="[0-9.]+"' | head -1 | grep -oE '[0-9.]+')
HEIGHT=$(echo "$XML_CONTENT" | grep -oE 'height="[0-9.]+"' | head -1 | grep -oE '[0-9.]+')
# If not found, try w="X" h="Y" pattern
if [ -z "$WIDTH" ] || [ -z "$HEIGHT" ]; then
WIDTH=$(echo "$XML_CONTENT" | grep -oE '\bw="[0-9.]+"' | head -1 | grep -oE '[0-9.]+')
HEIGHT=$(echo "$XML_CONTENT" | grep -oE '\bh="[0-9.]+"' | head -1 | grep -oE '[0-9.]+')
fi
# If still not found, try size attribute
if [ -z "$WIDTH" ] || [ -z "$HEIGHT" ]; then
SIZE=$(echo "$XML_CONTENT" | grep -oE 'size="[0-9.]+x[0-9.]+"' | head -1)
if [ -n "$SIZE" ]; then
WIDTH=$(echo "$SIZE" | grep -oE '[0-9.]+' | head -1)
HEIGHT=$(echo "$SIZE" | grep -oE '[0-9.]+' | tail -1)
fi
fi
if [ -z "$WIDTH" ] || [ -z "$HEIGHT" ]; then
echo "Warning: Could not extract dimensions from get_metadata response" >&2
echo "XML preview: ${XML_CONTENT:0:500}" >&2
echo '{}'
exit 0
fi
# Round to integers
WIDTH=$(printf "%.0f" "$WIDTH")
HEIGHT=$(printf "%.0f" "$HEIGHT")
# Create metadata directory (per-file approach avoids race conditions)
METADATA_DIR="/tmp/figma-to-react/metadata"
mkdir -p "$METADATA_DIR"
# Sanitize node ID for filename (replace : with -)
SAFE_NODE_ID="${NODE_ID//:/-}"
METADATA_FILE="${METADATA_DIR}/${SAFE_NODE_ID}.json"
# Write per-nodeId file (atomic, no contention)
cat > "$METADATA_FILE" << EOF
{"nodeId": "$NODE_ID", "width": $WIDTH, "height": $HEIGHT}
EOF
# Save full XML for fix-collapsed-containers.sh to use
XML_FILE="${METADATA_DIR}/${SAFE_NODE_ID}.xml"
echo "$XML_CONTENT" > "$XML_FILE"
# Extract ALL node dimensions to a JSON map for quick lookup
# Parse: <frame id="237:2572" ... width="393" height="64">
# Output: {"237:2572": {"w": 393, "h": 64}, ...}
DIMENSIONS_FILE="${METADATA_DIR}/${SAFE_NODE_ID}-dimensions.json"
# Use grep to extract all id/width/height from XML elements
# Match patterns like: id="237:2572" ... width="393" height="64"
echo "$XML_CONTENT" | grep -oE '<[^>]+ id="[^"]+"[^>]*>' | while read -r line; do
id=$(echo "$line" | grep -oE 'id="[^"]+"' | sed 's/id="//;s/"//')
w=$(echo "$line" | grep -oE 'width="[0-9.]+"' | grep -oE '[0-9.]+')
h=$(echo "$line" | grep -oE 'height="[0-9.]+"' | grep -oE '[0-9.]+')
if [ -n "$id" ] && [ -n "$w" ] && [ -n "$h" ]; then
# Round to integers
w=$(printf "%.0f" "$w")
h=$(printf "%.0f" "$h")
echo "\"$id\": {\"w\": $w, \"h\": $h}"
fi
done | paste -sd ',' - | sed 's/^/{/;s/$/}/' > "$DIMENSIONS_FILE"
# Count how many dimensions we extracted
DIM_COUNT=$(grep -c '"w":' "$DIMENSIONS_FILE" 2>/dev/null || echo "0")
# ============================================================================
# Extract parent-child instance mapping for fix-component-instances.sh
# Format: {"parentId": [{"id": "...", "name": "...", "type": "instance", "w": X, "h": Y}, ...]}
# ============================================================================
INSTANCES_FILE="${METADATA_DIR}/${SAFE_NODE_ID}-instances.json"
# Parse XML to build parent-child structure using temp file for parent stack
TEMP_STACK=$(mktemp)
echo "root" > "$TEMP_STACK"
echo "$XML_CONTENT" | while IFS= read -r line; do
# Count leading spaces to determine depth
spaces=$(echo "$line" | sed 's/[^ ].*//' | wc -c)
spaces=$((spaces - 1))
depth=$((spaces / 2))
# Extract element type (frame, instance, text)
type=""
if echo "$line" | grep -q '<frame '; then type="frame"; fi
if echo "$line" | grep -q '<instance '; then type="instance"; fi
if echo "$line" | grep -q '<text '; then type="text"; fi
[ -z "$type" ] && continue
# Extract id, name, dimensions
id=$(echo "$line" | grep -oE 'id="[^"]+"' | sed 's/id="//;s/"//')
name=$(echo "$line" | grep -oE 'name="[^"]+"' | sed 's/name="//;s/"//')
w=$(echo "$line" | grep -oE 'width="[0-9.]+"' | grep -oE '[0-9.]+')
h=$(echo "$line" | grep -oE 'height="[0-9.]+"' | grep -oE '[0-9.]+')
# Round decimals to integers (matching dimensions map behavior)
w=${w:+$(printf "%.0f" "$w")}
h=${h:+$(printf "%.0f" "$h")}
[ -z "$id" ] && continue
# Get parent from line (depth+1) in stack file
parent_id=$(sed -n "$((depth + 1))p" "$TEMP_STACK")
# Update stack: keep lines up to depth+1, add new id
head -n $((depth + 1)) "$TEMP_STACK" > "${TEMP_STACK}.tmp"
echo "$id" >> "${TEMP_STACK}.tmp"
mv "${TEMP_STACK}.tmp" "$TEMP_STACK"
# Output JSON line for jq to process
echo "{\"parent\": \"$parent_id\", \"id\": \"$id\", \"name\": \"$name\", \"type\": \"$type\", \"w\": ${w:-0}, \"h\": ${h:-0}}"
done | jq -s 'group_by(.parent) | map({(.[0].parent): [.[] | del(.parent)]}) | add // {}' > "$INSTANCES_FILE"
rm -f "$TEMP_STACK" "${TEMP_STACK}.tmp" 2>/dev/null
# Validate JSON (fix if empty or malformed)
if ! jq empty "$INSTANCES_FILE" 2>/dev/null; then
echo '{}' > "$INSTANCES_FILE"
fi
INSTANCE_COUNT=$(jq 'to_entries | map(.value | length) | add // 0' "$INSTANCES_FILE" 2>/dev/null || echo "0")
echo "✓ Captured dimensions for $NODE_ID: ${WIDTH}x${HEIGHT} (+${DIM_COUNT} child nodes, ${INSTANCE_COUNT} instances)" >&2
# Output JSON for hook system
echo '{}'
#!/bin/bash
#
# PostToolUse hook for capturing Figma MCP get_design_context responses
#
# ALWAYS captures responses to /tmp/figma-to-react/captures/figma-{nodeId}.txt
# ONLY suppresses output when skill is active (marker file exists)
#
# This ensures:
# - Verbatim code capture without LLM transcription modifications
# - Normal Figma MCP experience when skill is not active
# - Debug captures available even without skill
MARKER="/tmp/figma-to-react/capture-active"
OUTPUT_DIR="/tmp/figma-to-react/captures"
# Always read the full hook input
INPUT=$(cat)
# Extract nodeId for filename (convert : to - for filesystem safety)
NODE_ID=$(echo "$INPUT" | jq -r '.tool_input.nodeId // "unknown"' 2>/dev/null | tr ':' '-')
if [ -z "$NODE_ID" ] || [ "$NODE_ID" = "null" ]; then
NODE_ID="unknown-$(date +%s)"
fi
# Create output directory
mkdir -p "$OUTPUT_DIR"
# Extract the code from tool_response
# The response can be:
# - A JSON array: [{"type": "text", "text": "...code..."}, ...]
# - A JSON string containing a serialized array: "[{\"type\": \"text\", ...}]"
# - A raw string
# We need to get just the first text block which contains the React code
OUTPUT_FILE="${OUTPUT_DIR}/figma-${NODE_ID}.txt"
# Try to extract from JSON - handle both parsed arrays and serialized JSON strings
CODE=$(echo "$INPUT" | jq -r '
# Get tool_response
.tool_response as $resp |
# If it is an array, get first text element
if ($resp | type) == "array" then
$resp[0].text // empty
# If it is a string, try to parse it as JSON
elif ($resp | type) == "string" then
# Try to parse as JSON array
(try ($resp | fromjson) catch null) as $parsed |
if ($parsed | type) == "array" then
$parsed[0].text // empty
else
# Not parseable as array, return as-is
$resp
end
else
empty
end
' 2>/dev/null)
if [ -z "$CODE" ]; then
# Fallback: try to extract raw tool_response as string
CODE=$(echo "$INPUT" | jq -r '.tool_response // empty' 2>/dev/null)
fi
if [ -z "$CODE" ]; then
echo "Warning: Could not extract code from response" >&2
# No code to capture, allow normal flow
echo '{}'
exit 0
fi
# ALWAYS save extracted code (useful for debugging even without skill)
printf '%s' "$CODE" > "$OUTPUT_FILE"
BYTES=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
# CONDITIONAL: Only suppress output when skill is active
if [ -f "$MARKER" ]; then
# Skill is active - suppress raw output, replace with instruction
echo "✓ Captured: figma-${NODE_ID}.txt (${BYTES} bytes) [suppressing output]" >&2
cat << EOF
{
"suppressOutput": true,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "✅ Figma response captured to ${OUTPUT_FILE} (${BYTES} bytes)\n\nNEXT: Run the processing script to extract tokens and download assets:\n\n\$SKILL_DIR/scripts/process-figma.sh ${OUTPUT_FILE} <component.tsx> <asset-dir> <url-prefix> <tokens.css>"
}
}
EOF
else
# Skill not active - allow normal output (capture still happened for debugging)
echo "✓ Captured: figma-${NODE_ID}.txt (${BYTES} bytes) [passthrough]" >&2
echo '{}'
fi
#!/bin/bash
#
# PostToolUse hook for capturing Figma MCP get_screenshot responses
#
# Saves screenshot images to /tmp/figma-to-react/screenshots/figma-{nodeId}.png
# The image data comes as base64 in the tool_response
#
OUTPUT_DIR="/tmp/figma-to-react/screenshots"
# Read the full hook input
INPUT=$(cat)
# Extract nodeId for filename (convert : to - for filesystem safety)
NODE_ID=$(echo "$INPUT" | jq -r '.tool_input.nodeId // "unknown"' 2>/dev/null | tr ':' '-')
if [ -z "$NODE_ID" ] || [ "$NODE_ID" = "null" ]; then
NODE_ID="unknown-$(date +%s)"
fi
# Create output directory
mkdir -p "$OUTPUT_DIR"
OUTPUT_FILE="${OUTPUT_DIR}/figma-${NODE_ID}.png"
# Extract base64 image data from the response
# The response format is typically: [{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}]
# Or it could be: [{"type": "image", "data": "base64string", ...}]
# Try multiple extraction patterns
IMAGE_DATA=$(echo "$INPUT" | jq -r '
.tool_response as $resp |
# If array, get first image element
if ($resp | type) == "array" then
($resp[] | select(.type == "image") | .source.data // .data) // empty
# If string, try to parse
elif ($resp | type) == "string" then
(try ($resp | fromjson) catch null) as $parsed |
if ($parsed | type) == "array" then
($parsed[] | select(.type == "image") | .source.data // .data) // empty
else
empty
end
else
empty
end
' 2>/dev/null)
if [ -z "$IMAGE_DATA" ] || [ "$IMAGE_DATA" = "null" ]; then
# Fallback: try direct extraction patterns
IMAGE_DATA=$(echo "$INPUT" | jq -r '.tool_response[0].source.data // empty' 2>/dev/null)
fi
if [ -z "$IMAGE_DATA" ] || [ "$IMAGE_DATA" = "null" ]; then
IMAGE_DATA=$(echo "$INPUT" | jq -r '.tool_response[0].data // empty' 2>/dev/null)
fi
if [ -z "$IMAGE_DATA" ] || [ "$IMAGE_DATA" = "null" ]; then
echo "Warning: Could not extract image data from response" >&2
# Debug: save raw response for inspection
echo "$INPUT" | jq '.tool_response' > "${OUTPUT_DIR}/debug-${NODE_ID}.json" 2>/dev/null
echo '{}'
exit 0
fi
# Decode base64 and save as PNG
echo "$IMAGE_DATA" | base64 -d > "$OUTPUT_FILE" 2>/dev/null
if [ -f "$OUTPUT_FILE" ] && [ -s "$OUTPUT_FILE" ]; then
BYTES=$(wc -c < "$OUTPUT_FILE" | tr -d ' ')
echo "Screenshot saved: ${OUTPUT_FILE} (${BYTES} bytes)" >&2
# Return success with file path info
cat << EOF
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "Screenshot saved to ${OUTPUT_FILE}"
}
}
EOF
else
echo "Warning: Failed to decode image" >&2
echo '{}'
fi
#!/usr/bin/env bun
/**
* capture-screenshot.ts
*
* Capture a screenshot of a rendered component using headless Playwright.
* Screenshots the [data-figma-component] element at its natural size.
*
* Usage:
* bun capture-screenshot.ts <url> <output.png>
*
* Arguments:
* url - URL to capture (format depends on framework):
* Vite: http://localhost:5173/figma-preview.html?screen=Login
* Next.js: http://localhost:3000/figma-preview?screen=Login
* output - Output path for screenshot (e.g., /tmp/rendered-Login.png)
*
* Example:
* bun capture-screenshot.ts "http://localhost:5173/figma-preview.html?screen=Login" /tmp/rendered.png
*/
import { chromium } from 'playwright';
const url = process.argv[2];
const output = process.argv[3];
if (!url || !output) {
console.error('Usage: bun capture-screenshot.ts <url> <output.png>');
console.error('');
console.error('Arguments:');
console.error(' url - URL to capture');
console.error(' output - Output path for screenshot');
console.error('');
console.error('Screenshots the [data-figma-component] element at its natural size.');
process.exit(1);
}
async function capture() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
deviceScaleFactor: 2, // Retina-quality screenshots
});
const page = await context.newPage();
try {
// Use domcontentloaded instead of networkidle because Vite's HMR WebSocket
// keeps a persistent connection open, preventing networkidle from ever firing
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Find the component element and wait for it to be visible
const element = await page.locator('[data-figma-component]');
await element.waitFor({ state: 'visible', timeout: 10000 });
// Small delay for any animations to settle
await page.waitForTimeout(500);
const box = await element.boundingBox();
await element.screenshot({ path: output });
console.log(`Screenshot saved: ${output}`);
console.log(`Component size: ${box?.width}x${box?.height} @2x`);
} catch (error) {
console.error(`Error capturing screenshot: ${error}`);
process.exit(1);
} finally {
await browser.close();
}
}
capture();
#!/usr/bin/env bash
#
# extract-tokens.sh
#
# Extracts CSS variables from Figma MCP output and generates a tokens file.
# The MCP outputs var(--name,fallback) - we extract the name and fallback value.
#
# Usage:
# ./extract-tokens.sh <input-file> <output-css>
# ./extract-tokens.sh /tmp/figma-to-react/captures/figma-123-456.txt src/styles/figma-tokens.css
#
# Always overwrites output file. Deduplicates by variable name, preferring non-zero values.
set -e
INPUT="$1"
OUTPUT="$2"
if [ -z "$INPUT" ] || [ -z "$OUTPUT" ]; then
echo "Usage: $0 <input-file> <output-css>" >&2
exit 1
fi
if [ ! -f "$INPUT" ]; then
echo "Error: Input file not found: $INPUT" >&2
exit 1
fi
# Create output directory if needed
mkdir -p "$(dirname "$OUTPUT")"
# Temp file for new tokens (under /tmp/figma-to-react/ for easy cleanup)
TMP_DIR="/tmp/figma-to-react/tmp"
mkdir -p "$TMP_DIR"
TEMP_TOKENS="$TMP_DIR/figma-tokens-$$.txt"
trap "rm -f $TEMP_TOKENS" EXIT
# Extract all var() patterns: var(--name,fallback)
# Handle both escaped slashes (--name\/sub) and regular names (--name-sub)
# Fallback values can contain arbitrarily nested parens: calc((100% - max(20px, 5vw)) / 2)
# Use stack-based parenthesis matching for reliable extraction
perl -e '
use strict;
use warnings;
# Read entire file
my $content = do { local $/; <> };
# Find all var(--name, patterns and extract with balanced parens
while ($content =~ /var\((--[^,)]+),/g) {
my $name = $1;
my $start = pos($content);
my $depth = 1;
my $i = $start;
my $len = length($content);
# Stack-based parenthesis matching
while ($i < $len && $depth > 0) {
my $c = substr($content, $i, 1);
$depth++ if $c eq "(";
$depth-- if $c eq ")";
$i++;
}
if ($depth == 0) {
my $fallback = substr($content, $start, $i - $start - 1);
# Trim leading/trailing whitespace from fallback
$fallback =~ s/^\s+//;
$fallback =~ s/\s+$//;
# Convert escaped slashes to hyphens for cleaner CSS variable names
# e.g., --color\/primary\/500 → --color-primary-500
$name =~ s/\\?\//\-/g;
print "$name|$fallback\n";
}
}
' "$INPUT" | \
# Sort by name, then by value (reverse so non-"0px" comes before "0px")
sort -t'|' -k1,1 -k2,2r | \
# Keep first occurrence of each name (the non-zero value)
awk -F'|' '!seen[$1]++' > "$TEMP_TOKENS"
# Count tokens found
TOKEN_COUNT=$(wc -l < "$TEMP_TOKENS" | tr -d ' ')
if [ "$TOKEN_COUNT" -eq 0 ]; then
echo "No CSS variables found in input" >&2
exit 0
fi
echo "Found $TOKEN_COUNT unique CSS variables" >&2
# Write output CSS file (always overwrite - no merge)
{
echo "/* Figma Design Tokens - auto-generated */"
echo "/* Do not edit manually - regenerate with extract-tokens.sh */"
echo ":root {"
while IFS='|' read -r name fallback; do
echo " ${name}: ${fallback};"
done < "$TEMP_TOKENS"
echo "}"
} > "$OUTPUT"
echo "Written: $OUTPUT" >&2
#!/usr/bin/env bun
/**
* Find shared components across multiple Figma screen captures.
*
* Usage:
* bun find-shared-components.ts capture1.txt capture2.txt [capture3.txt ...]
*
* Output (JSON):
* {
* "shared": [
* {
* "definitionId": "2708:1961",
* "name": "exit button",
* "instances": [
* {"instanceId": "I237:2417;2708:1961", "screen": "237:2416"},
* {"instanceId": "I237:2572;2708:1961", "screen": "237:2571"}
* ]
* }
* ],
* "total_shared": 6
* }
*/
import fs from 'fs';
import path from 'path';
interface Instance {
instanceId: string;
screen: string;
}
interface SharedComponent {
definitionId: string;
name: string | null;
instances: Instance[];
}
interface Output {
shared: SharedComponent[];
total_shared: number;
}
/**
* Extract the component definition ID from an instance node ID.
* Instance IDs follow: I{parent};{component}[;{nested}...]
* Returns the last segment (the innermost component definition).
*/
function extractComponentDefinitionId(instanceId: string): string | null {
if (!instanceId.startsWith('I')) {
return null;
}
const parts = instanceId.split(';');
if (parts.length < 2) {
return null;
}
return parts[parts.length - 1];
}
/**
* Extract all node IDs from capture content.
*/
function extractNodeIds(content: string): string[] {
const pattern = /data-node-id="([^"]+)"/g;
const matches = content.matchAll(pattern);
return [...matches].map(m => m[1]);
}
/**
* Extract the screen's root node ID from the capture.
* Looks for the first data-node-id that's not an instance (doesn't start with I).
*/
function extractScreenNodeId(content: string): string {
const nodeIds = extractNodeIds(content);
for (const id of nodeIds) {
if (!id.startsWith('I')) {
return id;
}
}
// Fallback: use filename pattern
return 'unknown';
}
/**
* Extract element name from data-name attribute for a given node ID.
*/
function extractElementName(content: string, nodeId: string): string | null {
const escapedId = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Try both orderings of data-name and data-node-id
const pattern = new RegExp(
`data-name="([^"]+)"[^>]*data-node-id="${escapedId}"|data-node-id="${escapedId}"[^>]*data-name="([^"]+)"`
);
const match = content.match(pattern);
return match ? (match[1] || match[2]) : null;
}
/**
* Find shared components across multiple captures.
*/
function findSharedComponents(
captures: Array<{ screen: string; content: string }>
): SharedComponent[] {
const componentMap = new Map<string, SharedComponent>();
for (const capture of captures) {
const nodeIds = extractNodeIds(capture.content);
for (const nodeId of nodeIds) {
const definitionId = extractComponentDefinitionId(nodeId);
if (!definitionId) continue;
if (!componentMap.has(definitionId)) {
componentMap.set(definitionId, {
definitionId,
name: extractElementName(capture.content, nodeId),
instances: [],
});
}
const component = componentMap.get(definitionId)!;
// Avoid duplicates
const exists = component.instances.some(
i => i.instanceId === nodeId && i.screen === capture.screen
);
if (!exists) {
component.instances.push({
instanceId: nodeId,
screen: capture.screen,
});
}
}
}
// Filter to components appearing in 2+ different screens
return [...componentMap.values()].filter(c => {
const uniqueScreens = new Set(c.instances.map(i => i.screen));
return uniqueScreens.size > 1;
});
}
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: bun find-shared-components.ts capture1.txt capture2.txt [...]');
console.error('Need at least 2 capture files to find shared components.');
process.exit(1);
}
const captures: Array<{ screen: string; content: string }> = [];
for (const filePath of args) {
if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`);
process.exit(1);
}
const content = fs.readFileSync(filePath, 'utf-8');
const screen = extractScreenNodeId(content);
// Also try to extract from filename (figma-237-2571.txt → 237:2571)
const filenameMatch = path.basename(filePath).match(/figma-(\d+)-(\d+)/);
const screenFromFilename = filenameMatch ? `${filenameMatch[1]}:${filenameMatch[2]}` : screen;
captures.push({
screen: screenFromFilename,
content,
});
}
const shared = findSharedComponents(captures);
const output: Output = {
shared,
total_shared: shared.length,
};
console.log(JSON.stringify(output, null, 2));
}
main();
#!/usr/bin/env bash
#
# fix-collapsed-containers.sh
#
# Fix containers that collapse because all children are absolute-positioned.
# Adds explicit h-[Xpx] or w-[Xpx] classes from Figma metadata.
#
# Two-pass approach:
# Pass 1: Build map of ComponentName -> node-id from function definitions
# Pass 2: Fix both inline elements (with data-node-id) AND component usages
#
# Usage modes:
# # Single file pair (outputs to stdout - backward compatible)
# ./fix-collapsed-containers.sh <tsx-file> <dimensions-json>
#
# # Directory mode (auto-match files, fix in-place)
# ./fix-collapsed-containers.sh <tsx-dir> <dimensions-dir>
#
# # Multiple pairs (fix in-place)
# ./fix-collapsed-containers.sh --pair <tsx1> <dims1> [--pair <tsx2> <dims2>]...
#
# Examples:
# # Single file (stdout)
# ./fix-collapsed-containers.sh component.tsx dimensions.json > fixed.tsx
#
# # Directory mode (in-place)
# ./fix-collapsed-containers.sh src/persona/ /tmp/figma-to-react/metadata/
#
# # Multiple pairs (in-place)
# ./fix-collapsed-containers.sh \
# --pair src/Screen1.tsx metadata/1-1-dimensions.json \
# --pair src/Screen2.tsx metadata/2-2-dimensions.json
set -e
# ============================================================================
# Core processing function - takes tsx and dims, outputs to stdout
# ============================================================================
process_single_file() {
local TSX_FILE="$1"
local DIMENSIONS_JSON="$2"
local QUIET="${3:-false}"
if [ ! -f "$TSX_FILE" ]; then
echo "Error: TSX file not found: $TSX_FILE" >&2
return 1
fi
if [ ! -f "$DIMENSIONS_JSON" ]; then
# Just output original if no dimensions available
cat "$TSX_FILE"
return 0
fi
# Function to lookup dimensions from JSON
lookup_dimensions() {
local node_id="$1"
jq -r --arg id "$node_id" '.[$id] // empty | "\(.w)x\(.h)"' "$DIMENSIONS_JSON" 2>/dev/null
}
# Function to check if dimensions were manually added
is_manual_dimension() {
local node_id="$1"
jq -e --arg id "$node_id" '.[$id].manual // false' "$DIMENSIONS_JSON" &>/dev/null
}
# ============================================================================
# PASS 1: Build component name -> node-id map
# ============================================================================
declare -A COMPONENT_NODE_IDS
TEMP_MAP=$(mktemp)
trap "rm -f $TEMP_MAP" RETURN
current_component=""
in_component=false
while IFS= read -r line; do
if echo "$line" | grep -qE '^function [A-Z][A-Za-z0-9_]+\s*[(<]'; then
current_component=$(echo "$line" | grep -oE 'function [A-Z][A-Za-z0-9_]+' | sed 's/function //')
in_component=true
fi
if [ "$in_component" = true ] && [ -n "$current_component" ]; then
if echo "$line" | grep -qE 'data-node-id="[^"]+".*className=\{className\}|className=\{className\}.*data-node-id="[^"]+"'; then
node_id=$(echo "$line" | grep -oE 'data-node-id="[^"]+"' | head -1 | sed 's/data-node-id="//;s/"//')
if [ -n "$node_id" ]; then
echo "$current_component=$node_id" >> "$TEMP_MAP"
[ "$QUIET" = false ] && echo " Mapped component: $current_component -> $node_id" >&2
fi
in_component=false
current_component=""
fi
if echo "$line" | grep -qE '^(function |export |const [A-Z])' && ! echo "$line" | grep -qE "^function $current_component"; then
in_component=false
current_component=""
fi
fi
done < "$TSX_FILE"
while IFS='=' read -r comp_name node_id; do
COMPONENT_NODE_IDS["$comp_name"]="$node_id"
done < "$TEMP_MAP"
[ "$QUIET" = false ] && echo " Found ${#COMPONENT_NODE_IDS[@]} component mappings" >&2
# ============================================================================
# PASS 2: Process lines and apply fixes
# ============================================================================
FIXES_MADE=0
needs_height_fix_conservative() {
local line="$1"
echo "$line" | grep -qE 'py-\[|p-\[' && ! echo "$line" | grep -qE 'h-\[[0-9]+px\]|h-full|h-auto|h-fit|size-full|h-\[var'
}
needs_height_fix_aggressive() {
local line="$1"
echo "$line" | grep -qE 'py-\[|p-\[' && ! echo "$line" | grep -qE 'h-\[[0-9]+px\]|size-full|h-\[var'
}
has_relative_height() {
local line="$1"
echo "$line" | grep -qE '(^|[" ])h-(full|auto|fit)[" ]'
}
needs_width_fix_conservative() {
local line="$1"
echo "$line" | grep -qE 'px-\[|p-\[' && ! echo "$line" | grep -qE 'w-\[[0-9]+px\]|w-full|w-auto|w-fit|size-full|w-\[var'
}
needs_width_fix_aggressive() {
local line="$1"
echo "$line" | grep -qE 'px-\[|p-\[' && ! echo "$line" | grep -qE 'w-\[[0-9]+px\]|size-full|w-\[var'
}
has_relative_width() {
local line="$1"
echo "$line" | grep -qE '(^|[" ])w-(full|auto|fit)[" ]'
}
has_positioning() {
local line="$1"
echo "$line" | grep -qE 'relative|absolute'
}
while IFS= read -r line || [ -n "$line" ]; do
modified_line="$line"
node_id=""
if echo "$line" | grep -qE 'data-node-id="[^"]+"'; then
node_id=$(echo "$line" | grep -oE 'data-node-id="[^"]+"' | sed 's/data-node-id="//;s/"//')
fi
if [ -z "$node_id" ]; then
component_name=$(echo "$line" | grep -oE '<[A-Z][A-Za-z0-9_]+\s+className=' | sed 's/<//;s/[[:space:]]*className=//;s/[[:space:]]//g' | head -1)
if [ -n "$component_name" ] && [ -n "${COMPONENT_NODE_IDS[$component_name]}" ]; then
node_id="${COMPONENT_NODE_IDS[$component_name]}"
fi
fi
if [ -n "$node_id" ]; then
dims=$(lookup_dimensions "$node_id")
if [ -n "$dims" ]; then
w=$(echo "$dims" | cut -d'x' -f1)
h=$(echo "$dims" | cut -d'x' -f2)
if has_positioning "$line"; then
is_manual=false
if is_manual_dimension "$node_id"; then
is_manual=true
fi
if [ "$h" -gt 0 ]; then
if [ "$is_manual" = true ] && needs_height_fix_aggressive "$line"; then
if has_relative_height "$modified_line"; then
old_class=$(echo "$modified_line" | grep -oE '(^|[" ])h-(full|auto|fit)' | sed 's/^[" ]*//' | head -1)
new_line=$(echo "$modified_line" | sed -E "s/([\" ])h-(full|auto|fit)([\" ])/\1h-[${h}px]\3/g")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed height: $node_id -> h-[${h}px] (replaced $old_class, manual)" >&2
fi
else
new_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}h-[${h}px] /")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed height: $node_id -> h-[${h}px] (manual)" >&2
fi
fi
elif [ "$is_manual" = false ] && needs_height_fix_conservative "$line"; then
new_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}h-[${h}px] /")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed height: $node_id -> h-[${h}px]" >&2
fi
fi
fi
if [ "$w" -gt 0 ]; then
if [ "$is_manual" = true ] && needs_width_fix_aggressive "$line"; then
if has_relative_width "$modified_line"; then
old_class=$(echo "$modified_line" | grep -oE '(^|[" ])w-(full|auto|fit)' | sed 's/^[" ]*//' | head -1)
new_line=$(echo "$modified_line" | sed -E "s/([\" ])w-(full|auto|fit)([\" ])/\1w-[${w}px]\3/g")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed width: $node_id -> w-[${w}px] (replaced $old_class, manual)" >&2
fi
else
new_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}w-[${w}px] /")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed width: $node_id -> w-[${w}px] (manual)" >&2
fi
fi
elif [ "$is_manual" = false ] && needs_width_fix_conservative "$line"; then
new_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}w-[${w}px] /")
if [ "$new_line" != "$modified_line" ]; then
modified_line="$new_line"
FIXES_MADE=$((FIXES_MADE + 1))
[ "$QUIET" = false ] && echo " Fixed width: $node_id -> w-[${w}px]" >&2
fi
fi
fi
fi
fi
fi
echo "$modified_line"
done < "$TSX_FILE"
if [ "$FIXES_MADE" -gt 0 ]; then
echo "✓ Applied $FIXES_MADE collapsed container fixes to $(basename "$TSX_FILE")" >&2
else
[ "$QUIET" = false ] && echo "✓ No collapsed containers in $(basename "$TSX_FILE")" >&2
fi
# Return fix count via exit code (capped at 125 for safety)
return 0
}
# ============================================================================
# Mode detection and dispatch
# ============================================================================
if ! command -v jq &>/dev/null; then
echo "Error: jq is required for fix-collapsed-containers.sh" >&2
exit 1
fi
# Mode 3: Multiple pairs (--pair flag)
if [ "$1" = "--pair" ]; then
TOTAL_FIXES=0
FILES_PROCESSED=0
while [ $# -gt 0 ]; do
if [ "$1" = "--pair" ]; then
shift
if [ $# -lt 2 ]; then
echo "Error: --pair requires <tsx-file> <dimensions-json>" >&2
exit 1
fi
TSX_FILE="$1"
DIMS_FILE="$2"
shift 2
if [ ! -f "$TSX_FILE" ]; then
echo "Error: TSX file not found: $TSX_FILE" >&2
exit 1
fi
echo "Processing $(basename "$TSX_FILE")..." >&2
TEMP_OUTPUT=$(mktemp)
process_single_file "$TSX_FILE" "$DIMS_FILE" true > "$TEMP_OUTPUT"
mv "$TEMP_OUTPUT" "$TSX_FILE"
FILES_PROCESSED=$((FILES_PROCESSED + 1))
else
echo "Error: Expected --pair, got: $1" >&2
exit 1
fi
done
echo "✓ Processed $FILES_PROCESSED files" >&2
exit 0
fi
# Mode 2: Directory mode (both args are directories)
if [ -d "$1" ] && [ -d "$2" ]; then
TSX_DIR="$1"
DIMS_DIR="$2"
TOTAL_FIXES=0
FILES_PROCESSED=0
echo "=== Batch Processing ===" >&2
echo "TSX directory: $TSX_DIR" >&2
echo "Dimensions directory: $DIMS_DIR" >&2
# Find all tsx files and try to match with dimensions
for tsx_file in "$TSX_DIR"/*.tsx; do
[ -f "$tsx_file" ] || continue
# Try to find matching dimensions file
# Extract potential node IDs from the tsx filename or content
basename_tsx=$(basename "$tsx_file" .tsx)
# Look for dimensions files that might match
MATCHED_DIMS=""
for dims_file in "$DIMS_DIR"/*-dimensions.json; do
[ -f "$dims_file" ] || continue
# Extract node ID from dimensions filename (e.g., 237-2571-dimensions.json -> 237-2571)
dims_node_id=$(basename "$dims_file" -dimensions.json)
# Check if the tsx file references this node ID
if grep -q "data-node-id=\"${dims_node_id//-/:}\"" "$tsx_file" 2>/dev/null; then
MATCHED_DIMS="$dims_file"
break
fi
done
if [ -n "$MATCHED_DIMS" ]; then
echo "Processing $(basename "$tsx_file") with $(basename "$MATCHED_DIMS")..." >&2
TEMP_OUTPUT=$(mktemp)
process_single_file "$tsx_file" "$MATCHED_DIMS" true > "$TEMP_OUTPUT"
mv "$TEMP_OUTPUT" "$tsx_file"
FILES_PROCESSED=$((FILES_PROCESSED + 1))
fi
done
if [ "$FILES_PROCESSED" -eq 0 ]; then
echo "Warning: No matching tsx/dimensions pairs found" >&2
else
echo "✓ Processed $FILES_PROCESSED files" >&2
fi
exit 0
fi
# Mode 1: Single file pair (backward compatible - stdout)
TSX_FILE="$1"
DIMENSIONS_JSON="$2"
if [ -z "$TSX_FILE" ] || [ -z "$DIMENSIONS_JSON" ]; then
echo "Usage:" >&2
echo " $0 <tsx-file> <dimensions-json> # Single file (stdout)" >&2
echo " $0 <tsx-dir> <dimensions-dir> # Directory mode (in-place)" >&2
echo " $0 --pair <tsx1> <dims1> [--pair <tsx2>...] # Multiple pairs (in-place)" >&2
exit 1
fi
if [ ! -f "$TSX_FILE" ]; then
echo "Error: TSX file not found: $TSX_FILE" >&2
exit 1
fi
if [ ! -f "$DIMENSIONS_JSON" ]; then
echo "Error: Dimensions JSON not found: $DIMENSIONS_JSON" >&2
cat "$TSX_FILE"
exit 0
fi
process_single_file "$TSX_FILE" "$DIMENSIONS_JSON" false
#!/usr/bin/env bash
#
# fix-component-instances.sh
#
# Adds dimensions to component usages based on parent context + name matching.
# Solves the problem where component usages like <NavigationBar className="..."/>
# don't have data-node-id and thus miss their instance dimensions.
#
# Algorithm:
# 1. Build map: parentId -> [{name, type, w, h}, ...]
# 2. For each component usage <ComponentName className="...">:
# a. Find nearest ancestor data-node-id -> parentId
# b. Look up instances under parentId
# c. Match by name (normalize: remove spaces, case-insensitive)
# d. Track usage count per parent to handle duplicates
# e. Add h-[Xpx] if missing and instance has height
#
# Usage:
# ./fix-component-instances.sh <tsx-file> <instances-json>
#
# Arguments:
# tsx-file - Path to generated TSX file
# instances-json - Path to instances JSON from capture-figma-metadata.sh
#
# Output:
# Writes fixed code to stdout
set -e
TSX_FILE="$1"
INSTANCES_JSON="$2"
if [ -z "$TSX_FILE" ] || [ -z "$INSTANCES_JSON" ]; then
echo "Usage: $0 <tsx-file> <instances-json>" >&2
exit 1
fi
if [ ! -f "$TSX_FILE" ]; then
echo "Error: TSX file not found: $TSX_FILE" >&2
exit 1
fi
if [ ! -f "$INSTANCES_JSON" ]; then
# No instances file, just output original
cat "$TSX_FILE"
exit 0
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required" >&2
cat "$TSX_FILE"
exit 0
fi
# Normalize component name: "Navigation Bar" -> "navigationbar"
normalize_name() {
echo "$1" | tr '[:upper:]' '[:lower:]' | tr -d ' _-'
}
# Load instances into a format we can query
# Create temp files for each parent's instances
TEMP_DIR=$(mktemp -d)
trap "rm -rf $TEMP_DIR" EXIT
# Extract all parent IDs and their instance children
jq -r 'to_entries[] | .key as $parent | .value[] | select(.type == "instance") | "\($parent)\t\(.name)\t\(.w)\t\(.h)"' "$INSTANCES_JSON" > "$TEMP_DIR/all_instances.tsv"
# Track current parent context as we process lines
current_parent=""
FIXES_MADE=0
# For multiline component handling
pending_comp=""
pending_comp_line=""
pending_comp_parent=""
# Process TSX file
while IFS= read -r line || [ -n "$line" ]; do
modified_line="$line"
# Update current parent context if we see data-node-id
if echo "$line" | grep -qE 'data-node-id="[^"]+"'; then
node_id=$(echo "$line" | grep -oE 'data-node-id="[^"]+"' | tail -1 | sed 's/data-node-id="//;s/"//')
if [ -n "$node_id" ]; then
current_parent="$node_id"
fi
fi
# Check for start of multiline component: <ComponentName (without className on same line)
if echo "$line" | grep -qE '<[A-Z][a-zA-Z0-9_]+$' || echo "$line" | grep -qE '<[A-Z][a-zA-Z0-9_]+[[:space:]]*$'; then
pending_comp=$(echo "$line" | grep -oE '<[A-Z][a-zA-Z0-9_]+' | sed 's/<//')
pending_comp_parent="$current_parent"
pending_comp_line="$line"
fi
# Check if this is className line for a pending multiline component
if [ -n "$pending_comp" ] && echo "$line" | grep -qE '^[[:space:]]*className='; then
if [ -n "$pending_comp_parent" ]; then
normalized_comp=$(normalize_name "$pending_comp")
while IFS=$'\t' read -r parent name w h; do
if [ "$parent" = "$pending_comp_parent" ]; then
normalized_inst=$(normalize_name "$name")
if [ "$normalized_comp" = "$normalized_inst" ]; then
if ! echo "$line" | grep -qE 'h-\[[0-9]+px\]|size-full'; then
if [ "$h" -gt 0 ] 2>/dev/null; then
modified_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}h-[${h}px] /")
FIXES_MADE=$((FIXES_MADE + 1))
echo " Fixed: $pending_comp in parent $pending_comp_parent -> h-[${h}px] (multiline)" >&2
fi
fi
break
fi
fi
done < "$TEMP_DIR/all_instances.tsv"
fi
pending_comp=""
pending_comp_parent=""
pending_comp_line=""
fi
# Check if this is a single-line component usage: <ComponentName className="...">
if echo "$line" | grep -qE '<[A-Z][a-zA-Z0-9_]+ [^>]*className='; then
comp_name=$(echo "$line" | grep -oE '<[A-Z][a-zA-Z0-9_]+' | sed 's/<//')
if [ -n "$comp_name" ] && [ -n "$current_parent" ]; then
normalized_comp=$(normalize_name "$comp_name")
while IFS=$'\t' read -r parent name w h; do
if [ "$parent" = "$current_parent" ]; then
normalized_inst=$(normalize_name "$name")
if [ "$normalized_comp" = "$normalized_inst" ]; then
if ! echo "$line" | grep -qE 'h-\[[0-9]+px\]|size-full'; then
if [ "$h" -gt 0 ] 2>/dev/null; then
modified_line=$(echo "$modified_line" | perl -pe "s/(className=\")/\${1}h-[${h}px] /")
FIXES_MADE=$((FIXES_MADE + 1))
echo " Fixed: $comp_name in parent $current_parent -> h-[${h}px]" >&2
fi
fi
break
fi
fi
done < "$TEMP_DIR/all_instances.tsv"
fi
fi
# Reset pending if we hit closing tag or different element
if [ -n "$pending_comp" ] && echo "$line" | grep -qE '/>|>'; then
if ! echo "$line" | grep -qE 'className='; then
pending_comp=""
pending_comp_parent=""
fi
fi
echo "$modified_line"
done < "$TSX_FILE"
if [ "$FIXES_MADE" -gt 0 ]; then
echo " Applied $FIXES_MADE component instance dimension fixes" >&2
fi
#!/usr/bin/env bash
#
# process-figma.sh
#
# All-in-one Figma MCP processor:
# 1. Extracts design tokens → CSS variables file
# 2. Downloads assets with content-hash deduplication
# 3. Replaces Figma URLs with local paths
# 4. Outputs production-ready component
#
# Usage:
# ./process-figma.sh <input> <output> <asset-dir> <url-prefix> [tokens-file]
#
# Example:
# ./process-figma.sh \
# /tmp/figma-to-react/captures/figma-237-2571.txt \
# src/components/MyScreen.tsx \
# public/figma-assets \
# /figma-assets \
# src/styles/figma-tokens.css
set -e
# Cross-platform sed -i (BSD vs GNU)
sed_i() {
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "$@"
else
sed -i "$@"
fi
}
INPUT="$1"
OUTPUT="$2"
ASSET_DIR="$3"
URL_PREFIX="$4"
TOKENS_FILE="${5:-}"
if [ -z "$INPUT" ] || [ -z "$OUTPUT" ] || [ -z "$ASSET_DIR" ] || [ -z "$URL_PREFIX" ]; then
echo "Usage: $0 <input> <output> <asset-dir> <url-prefix> [tokens-file]" >&2
echo "" >&2
echo "Arguments:" >&2
echo " input - Captured MCP response file" >&2
echo " output - Output component path (.tsx)" >&2
echo " asset-dir - Directory to save downloaded assets" >&2
echo " url-prefix - URL prefix for assets in code (e.g., /assets)" >&2
echo " tokens-file - Optional. CSS tokens file path" >&2
exit 1
fi
if [ ! -f "$INPUT" ]; then
echo "Error: Input file not found: $INPUT" >&2
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Create directories
mkdir -p "$ASSET_DIR"
mkdir -p "$(dirname "$OUTPUT")"
# Temp files (all under /tmp/figma-to-react/ for easy cleanup)
TMP_DIR="/tmp/figma-to-react/tmp"
mkdir -p "$TMP_DIR"
TEMP_CODE="$TMP_DIR/figma-code-$$.txt"
ASSET_LIST="$TMP_DIR/figma-assets-$$.txt"
MAPPING_FILE="$TMP_DIR/figma-mapping-$$.txt"
HASH_MAP="$TMP_DIR/figma-hashes-$$.txt"
trap "rm -f $TEMP_CODE $ASSET_LIST $MAPPING_FILE $HASH_MAP $TMP_DIR/figma-dl-$$-*.bin" EXIT
# Extract nodeId from input filename (e.g., figma-237-2571.txt -> 237-2571)
NODE_ID=$(basename "$INPUT" | sed -E 's/^figma-(.+)\.txt$/\1/')
# Read dimensions from metadata (saved by capture-figma-metadata.sh hook)
METADATA_FILE="/tmp/figma-to-react/metadata/${NODE_ID}.json"
if [ -f "$METADATA_FILE" ]; then
FRAME_WIDTH=$(jq -r '.width' "$METADATA_FILE" 2>/dev/null)
FRAME_HEIGHT=$(jq -r '.height' "$METADATA_FILE" 2>/dev/null)
# Validate dimensions are positive integers (security: prevent injection)
if ! [[ "$FRAME_WIDTH" =~ ^[0-9]+$ ]] || ! [[ "$FRAME_HEIGHT" =~ ^[0-9]+$ ]]; then
FRAME_WIDTH=""
FRAME_HEIGHT=""
fi
else
FRAME_WIDTH=""
FRAME_HEIGHT=""
fi
echo "=== Figma → React Processor ===" >&2
echo "" >&2
# Step 1: Extract tokens (if tokens file specified)
if [ -n "$TOKENS_FILE" ]; then
echo "Step 1: Extracting design tokens..." >&2
if [ -x "$SCRIPT_DIR/extract-tokens.sh" ]; then
"$SCRIPT_DIR/extract-tokens.sh" "$INPUT" "$TOKENS_FILE"
else
# Inline token extraction if script not available
mkdir -p "$(dirname "$TOKENS_FILE")"
{
echo "/* Figma Design Tokens - auto-generated */"
echo ":root {"
grep -oE 'var\(--[^)]+\)' "$INPUT" | sort -u | while read -r var; do
name=$(echo "$var" | sed -E 's/var\((--[^,]+),.*/\1/')
fallback=$(echo "$var" | sed -E 's/var\([^,]+,([^)]+)\)/\1/')
[ -n "$name" ] && [ -n "$fallback" ] && echo " ${name}: ${fallback};"
done
echo "}"
} > "$TOKENS_FILE"
echo "Written: $TOKENS_FILE" >&2
fi
echo "" >&2
else
echo "Step 1: Skipping token extraction (no tokens file specified)" >&2
echo "" >&2
fi
# Step 2: Extract and download assets
echo "Step 2: Processing assets..." >&2
# Extract all asset URLs and their variable names
# Format: varName|url
# Use perl for reliable cross-platform regex
# Supports both:
# - Remote Figma MCP: https://figma.com/api/mcp/asset/...
# - Local Figma Desktop MCP: http://localhost:PORT/assets/...
perl -ne 'if (/const\s+(\w+)\s*=\s*"(https?:\/\/(?:(?:www\.)?figma\.com\/api\/mcp\/asset|localhost:\d+\/assets)\/[^"]+)"/) { print "$1|$2\n"; }' "$INPUT" > "$ASSET_LIST" || true
TOTAL_REFS=$(wc -l < "$ASSET_LIST" | tr -d ' ')
UNIQUE_URLS=$(cut -d'|' -f2 "$ASSET_LIST" | sort -u | wc -l | tr -d ' ')
echo " Found $TOTAL_REFS asset references ($UNIQUE_URLS unique URLs)" >&2
> "$MAPPING_FILE"
> "$HASH_MAP"
if [ "$TOTAL_REFS" -gt 0 ]; then
echo " Downloading with content-hash deduplication..." >&2
for URL in $(cut -d'|' -f2 "$ASSET_LIST" | sort -u); do
[ -z "$URL" ] && continue
# Get variable name for this URL (for naming)
VAR_NAME=$(grep "|${URL}$" "$ASSET_LIST" | head -1 | cut -d'|' -f1)
# Derive base filename from variable name
BASE_NAME=$(echo "$VAR_NAME" | \
sed -E 's/^img([A-Z])/\1/' | \
sed -E 's/^img$/asset/' | \
sed -E 's/^img([0-9])/asset-\1/' | \
sed -E 's/([a-z])([A-Z])/\1-\2/g' | \
tr '[:upper:]' '[:lower:]')
# Download to temp
TEMP_FILE="$TMP_DIR/figma-dl-$$-${BASE_NAME}.bin"
if ! curl -sL "$URL" -o "$TEMP_FILE" 2>/dev/null; then
echo " ✗ Failed: $BASE_NAME" >&2
continue
fi
# Hash content for deduplication
HASH=$(md5 -q "$TEMP_FILE" 2>/dev/null || md5sum "$TEMP_FILE" | cut -d' ' -f1)
# Check if we already have this content
EXISTING=$(grep "^$HASH|" "$HASH_MAP" 2>/dev/null | cut -d'|' -f2 || true)
if [ -n "$EXISTING" ]; then
# Duplicate content - reuse existing file
echo " ↔ Duplicate: $BASE_NAME → $EXISTING" >&2
rm "$TEMP_FILE"
URL_PATH="$EXISTING"
else
# New unique content
FILE_TYPE=$(file -b "$TEMP_FILE" 2>/dev/null || echo "unknown")
case "$FILE_TYPE" in
*"SVG"*) EXT="svg" ;;
*"PNG"*) EXT="png" ;;
*"JPEG"*|*"JPG"*) EXT="jpg" ;;
*"GIF"*) EXT="gif" ;;
*"WebP"*) EXT="webp" ;;
*)
# Check for SVG content
if head -c 200 "$TEMP_FILE" 2>/dev/null | grep -q "<svg"; then
EXT="svg"
else
EXT="png"
fi
;;
esac
FILENAME="${BASE_NAME}.${EXT}"
LOCAL_PATH="${ASSET_DIR}/${FILENAME}"
URL_PATH="${URL_PREFIX}/${FILENAME}"
# Handle filename collision (different content, same derived name)
if [ -f "$LOCAL_PATH" ]; then
SHORT_HASH="${HASH:0:6}"
FILENAME="${BASE_NAME}-${SHORT_HASH}.${EXT}"
LOCAL_PATH="${ASSET_DIR}/${FILENAME}"
URL_PATH="${URL_PREFIX}/${FILENAME}"
fi
mv "$TEMP_FILE" "$LOCAL_PATH"
echo "$HASH|$URL_PATH" >> "$HASH_MAP"
echo " ✓ Downloaded: $FILENAME" >&2
fi
# Map original URL → local path
echo "$URL|$URL_PATH" >> "$MAPPING_FILE"
done
UNIQUE_FILES=$(wc -l < "$HASH_MAP" | tr -d ' ')
echo " Saved $UNIQUE_FILES unique files (deduplicated from $UNIQUE_URLS URLs)" >&2
fi
echo "" >&2
# Step 3: Transform code
echo "Step 3: Generating component..." >&2
# Start with the input file
cp "$INPUT" "$TEMP_CODE"
# Strip MCP instructions that appear after the React code
# These markers indicate where Figma's guidance text begins
perl -i -0777 -pe 's/(SUPER CRITICAL:|Node ids have been added|These styles are contained|Component descriptions:|IMPORTANT: After you call this tool).*//s' "$TEMP_CODE"
# Convert escaped slashes to hyphens in CSS variable references
# e.g., var(--color\/primary\/500, #fff) → var(--color-primary-500, #fff)
# This matches the token name cleanup in extract-tokens.sh
# Pattern matches CSS var names (--xxx) and replaces all slashes with hyphens in one pass
perl -i -pe 's/--[^\s,)]+/my $s=$&; $s=~s{\\?\/}{-}g; $s/ge' "$TEMP_CODE"
# Remove asset const declarations (const imgXxx = "https://...")
perl -i -pe 's/^const\s+\w+\s*=\s*"https?:\/\/(?:www\.figma\.com\/api\/mcp\/asset|localhost:\d+\/assets)\/[^"]+";?\s*\n?//gm' "$TEMP_CODE"
# Replace src={varName} with src="localPath"
while IFS='|' read -r VAR_NAME URL; do
[ -z "$VAR_NAME" ] && continue
LOCAL_PATH=$(grep "^${URL}|" "$MAPPING_FILE" 2>/dev/null | head -1 | cut -d'|' -f2)
[ -z "$LOCAL_PATH" ] && continue
# Handle both src={var} and src={ var } patterns
sed_i "s|src={${VAR_NAME}}|src=\"${LOCAL_PATH}\"|g" "$TEMP_CODE"
sed_i "s|src={ ${VAR_NAME} }|src=\"${LOCAL_PATH}\"|g" "$TEMP_CODE"
done < "$ASSET_LIST"
# Note: size-full on root element is intentionally preserved.
# The preview wrapper (FigmaPreview.tsx) constrains components to exact Figma dimensions
# using inline styles (width, height, overflow:hidden). This allows components to be
# responsive in production while maintaining pixel-perfect validation screenshots.
# See: tests/e2e/skill-integration.test.ts "Preview Wrapper Dimension Tests"
# Step 3.5: Fix collapsed containers
# Containers with only absolute children collapse to padding-only height
DIMENSIONS_FILE="/tmp/figma-to-react/metadata/${NODE_ID}-dimensions.json"
if [ -f "$DIMENSIONS_FILE" ] && [ -x "$SCRIPT_DIR/fix-collapsed-containers.sh" ]; then
echo "" >&2
echo "Step 3.5: Fixing collapsed containers..." >&2
"$SCRIPT_DIR/fix-collapsed-containers.sh" "$TEMP_CODE" "$DIMENSIONS_FILE" > "$TEMP_CODE.fixed"
mv "$TEMP_CODE.fixed" "$TEMP_CODE"
fi
# Step 3.6: Fix component instance dimensions
# Component usages like <NavigationBar/> don't have data-node-id, so their
# instance dimensions are lost. This step matches them by parent context + name.
INSTANCES_FILE="/tmp/figma-to-react/metadata/${NODE_ID}-instances.json"
if [ -f "$INSTANCES_FILE" ] && [ -x "$SCRIPT_DIR/fix-component-instances.sh" ]; then
echo "" >&2
echo "Step 3.6: Fixing component instance dimensions..." >&2
"$SCRIPT_DIR/fix-component-instances.sh" "$TEMP_CODE" "$INSTANCES_FILE" > "$TEMP_CODE.fixed"
mv "$TEMP_CODE.fixed" "$TEMP_CODE"
fi
# Step 4: Inject dimension export for preview route
# Components export figmaDimensions so preview can set container size
if [ -n "$FRAME_WIDTH" ] && [ -n "$FRAME_HEIGHT" ]; then
echo "" >&2
echo "Step 4: Injecting dimension export..." >&2
DIMENSION_EXPORT="export const figmaDimensions = { width: ${FRAME_WIDTH}, height: ${FRAME_HEIGHT} };"
# Check if file starts with 'use client' (Next.js App Router)
# The directive MUST stay on line 1, so inject export after it
if head -1 "$TEMP_CODE" | grep -q "^['\"]use client['\"]"; then
# Insert after first line (preserving 'use client' at top)
sed_i "1a\\
\\
${DIMENSION_EXPORT}\\
" "$TEMP_CODE"
echo " Added after 'use client': $DIMENSION_EXPORT" >&2
else
# No 'use client' - prepend to file
echo -e "${DIMENSION_EXPORT}\n\n$(cat "$TEMP_CODE")" > "$TEMP_CODE"
echo " Added: $DIMENSION_EXPORT" >&2
fi
fi
# Write output
cp "$TEMP_CODE" "$OUTPUT"
echo " Written: $OUTPUT" >&2
echo "" >&2
# Summary
echo "=== Done ===" >&2
echo "" >&2
echo "Component: $OUTPUT" >&2
[ -n "$TOKENS_FILE" ] && echo "Tokens: $TOKENS_FILE" >&2
echo "Assets: $ASSET_DIR/ ($UNIQUE_FILES files)" >&2
echo "" >&2
echo "Next steps:" >&2
echo " 1. Import tokens in your CSS: @import \"$(basename "$TOKENS_FILE")\";" >&2
echo " 2. Rename component export if needed" >&2
echo " 3. Add interactivity (onClick, useState, etc.)" >&2
#!/usr/bin/env bash
#
# rename-assets.sh
#
# Renames generic asset files using Component descriptions from Figma MCP output.
# Updates references in the component file.
#
# The MCP output includes descriptions like:
# ## x
# **Node ID:** 3:439
# Source: boxicons --- icon, x, close
#
# This script parses those and renames:
# asset-abc123.svg → close-icon.svg (from "x, close")
# asset-def456.svg → arrow-back.svg (from "arrow, back")
#
# Usage:
# ./rename-assets.sh <captured-response> <asset-dir> <component-file-or-dir> [component-file2...]
#
# Examples:
# ./rename-assets.sh /tmp/figma-to-react/captures/figma-237-2571.txt public/figma-assets src/components/MyScreen.tsx
# ./rename-assets.sh /tmp/figma-to-react/captures/figma-237-2571.txt public/figma-assets src/components/
# ./rename-assets.sh /tmp/figma-to-react/captures/figma-237-2571.txt public/figma-assets src/A.tsx src/B.tsx
set -e
INPUT="$1"
ASSET_DIR="$2"
shift 2
COMPONENT_ARGS=("$@")
if [ -z "$INPUT" ] || [ -z "$ASSET_DIR" ] || [ ${#COMPONENT_ARGS[@]} -eq 0 ]; then
echo "Usage: $0 <captured-response> <asset-dir> <component-file-or-dir> [component-file2...]" >&2
exit 1
fi
if [ ! -f "$INPUT" ]; then
echo "Error: Input file not found: $INPUT" >&2
exit 1
fi
if [ ! -d "$ASSET_DIR" ]; then
echo "Error: Asset directory not found: $ASSET_DIR" >&2
exit 1
fi
# Build list of component files
COMPONENT_FILES=()
for ARG in "${COMPONENT_ARGS[@]}"; do
if [ -d "$ARG" ]; then
# Directory: find all .tsx files
while IFS= read -r F; do
COMPONENT_FILES+=("$F")
done < <(find "$ARG" -maxdepth 1 -name "*.tsx" -type f 2>/dev/null)
elif [ -f "$ARG" ]; then
COMPONENT_FILES+=("$ARG")
else
echo "Warning: Component not found: $ARG" >&2
fi
done
if [ ${#COMPONENT_FILES[@]} -eq 0 ]; then
echo "Error: No component files found" >&2
exit 1
fi
echo "Processing ${#COMPONENT_FILES[@]} component file(s)" >&2
echo "=== Asset Renamer ===" >&2
echo "" >&2
# Parse component descriptions from MCP output
# Looking for patterns like:
# ## x
# **Node ID:** 3:439
# Source: boxicons --- 🔎 icon, x, close
RENAME_COUNT=0
# Extract component description blocks
# Format: ## name\n**Node ID:** id\nSource: ... --- keywords
perl -0777 -ne '
while (/## (\w+[-\w]*)\s*\n\*\*Node ID:\*\*[^\n]+\n[^\n]*---[^\n]*🔎\s*([^\n]+)/g) {
my $name = $1;
my $keywords = $2;
# Clean up keywords
$keywords =~ s/^\s+|\s+$//g;
print "$name|$keywords\n";
}
' "$INPUT" | while IFS='|' read -r NAME KEYWORDS; do
[ -z "$NAME" ] && continue
# Parse keywords to generate meaningful filename
# "icon, x, close" → "close-icon"
# "icon, arrow, back" → "arrow-back"
# Extract the most meaningful keywords (skip generic ones like "icon")
MEANINGFUL=$(echo "$KEYWORDS" | tr ',' '\n' | sed 's/^\s*//;s/\s*$//' | \
grep -v -E '^(icon|image|img|logo|graphic)$' | head -2 | tr '\n' '-' | sed 's/-$//')
if [ -z "$MEANINGFUL" ]; then
MEANINGFUL="$NAME"
fi
# Look for assets that might match this component
# Check if there's an asset file we can rename
# Use find instead of brace expansion which doesn't work reliably with wildcards
while IFS= read -r ASSET_FILE; do
[ -f "$ASSET_FILE" ] || continue
BASENAME=$(basename "$ASSET_FILE")
EXT="${BASENAME##*.}"
# Check if this asset is referenced in any component with a generic name
FOUND_IN_COMPONENT=false
for COMPONENT in "${COMPONENT_FILES[@]}"; do
if grep -q "src=\"[^\"]*${BASENAME}\"" "$COMPONENT" 2>/dev/null; then
FOUND_IN_COMPONENT=true
break
fi
done
if $FOUND_IN_COMPONENT; then
# This asset is used - check if it has a generic name
if echo "$BASENAME" | grep -qE '^(asset|img|image)-'; then
NEW_NAME="${MEANINGFUL}.${EXT}"
NEW_PATH="$ASSET_DIR/$NEW_NAME"
# Avoid overwriting existing files
if [ -f "$NEW_PATH" ] && [ "$ASSET_FILE" != "$NEW_PATH" ]; then
# Cross-platform md5: macOS uses md5 -q, Linux uses md5sum
if command -v md5 &>/dev/null; then
SHORT_HASH=$(md5 -q "$ASSET_FILE" 2>/dev/null | cut -c1-4 || echo "xxxx")
elif command -v md5sum &>/dev/null; then
SHORT_HASH=$(md5sum "$ASSET_FILE" 2>/dev/null | cut -d' ' -f1 | cut -c1-4 || echo "xxxx")
else
SHORT_HASH="xxxx"
fi
NEW_NAME="${MEANINGFUL}-${SHORT_HASH}.${EXT}"
NEW_PATH="$ASSET_DIR/$NEW_NAME"
fi
if [ "$ASSET_FILE" != "$NEW_PATH" ]; then
echo " $BASENAME → $NEW_NAME" >&2
mv "$ASSET_FILE" "$NEW_PATH"
# Update references in all component files (cross-platform sed -i)
OLD_REF=$(basename "$ASSET_FILE")
for COMPONENT in "${COMPONENT_FILES[@]}"; do
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|${OLD_REF}|${NEW_NAME}|g" "$COMPONENT"
else
sed -i "s|${OLD_REF}|${NEW_NAME}|g" "$COMPONENT"
fi
done
RENAME_COUNT=$((RENAME_COUNT + 1))
fi
fi
fi
done < <(find "$ASSET_DIR" -maxdepth 1 -type f \( -name "*.svg" -o -name "*.png" -o -name "*.jpg" -o -name "*.gif" -o -name "*.webp" \) 2>/dev/null)
done
echo "" >&2
echo "Renamed $RENAME_COUNT assets" >&2
# ============================================================================
# Phase 2: Deduplicate identical assets
# ============================================================================
echo "" >&2
echo "=== Deduplicating Identical Assets ===" >&2
echo "" >&2
DEDUP_COUNT=0
# Build checksum map: hash -> list of files
declare -A CHECKSUM_MAP
# Helper: compute normalized checksum
# For SVGs, strip id attributes so visually-identical files match
compute_hash() {
local FILE="$1"
local EXT="${FILE##*.}"
local CONTENT
if [ "$EXT" = "svg" ]; then
# Normalize SVG: remove id attributes (they often differ but content is same)
CONTENT=$(sed -E 's/ id="[^"]*"//g' "$FILE" 2>/dev/null)
else
CONTENT=$(cat "$FILE" 2>/dev/null)
fi
if command -v md5 &>/dev/null; then
echo "$CONTENT" | md5 -q 2>/dev/null
elif command -v md5sum &>/dev/null; then
echo "$CONTENT" | md5sum 2>/dev/null | cut -d' ' -f1
fi
}
while IFS= read -r ASSET_FILE; do
[ -f "$ASSET_FILE" ] || continue
# Compute normalized checksum
HASH=$(compute_hash "$ASSET_FILE")
[ -z "$HASH" ] && continue
# Append to list (space-separated)
if [ -z "${CHECKSUM_MAP[$HASH]}" ]; then
CHECKSUM_MAP[$HASH]="$ASSET_FILE"
else
CHECKSUM_MAP[$HASH]="${CHECKSUM_MAP[$HASH]}|$ASSET_FILE"
fi
done < <(find "$ASSET_DIR" -maxdepth 1 -type f \( -name "*.svg" -o -name "*.png" -o -name "*.jpg" -o -name "*.gif" -o -name "*.webp" \) 2>/dev/null)
# Process each group of duplicates
for HASH in "${!CHECKSUM_MAP[@]}"; do
FILES="${CHECKSUM_MAP[$HASH]}"
# Skip if only one file with this hash
[[ "$FILES" != *"|"* ]] && continue
# Split into array
IFS='|' read -ra FILE_ARRAY <<< "$FILES"
# Pick the canonical file (prefer shorter name without numeric suffix)
CANONICAL=""
CANONICAL_SCORE=999
for FILE in "${FILE_ARRAY[@]}"; do
NAME=$(basename "$FILE")
SCORE=${#NAME}
# Penalize names with numeric suffixes like -2, -3
if echo "$NAME" | grep -qE '-[0-9]+\.'; then
SCORE=$((SCORE + 100))
fi
# Penalize names with hash suffixes
if echo "$NAME" | grep -qE '-[a-f0-9]{4,}\.'; then
SCORE=$((SCORE + 50))
fi
if [ $SCORE -lt $CANONICAL_SCORE ]; then
CANONICAL_SCORE=$SCORE
CANONICAL="$FILE"
fi
done
CANONICAL_NAME=$(basename "$CANONICAL")
# Remove duplicates and update references
for FILE in "${FILE_ARRAY[@]}"; do
[ "$FILE" = "$CANONICAL" ] && continue
DUP_NAME=$(basename "$FILE")
# Update references in all component files
for COMPONENT in "${COMPONENT_FILES[@]}"; do
if grep -q "$DUP_NAME" "$COMPONENT" 2>/dev/null; then
echo " $DUP_NAME → $CANONICAL_NAME (merged)" >&2
if [[ "$OSTYPE" == "darwin"* ]]; then
sed -i '' "s|$DUP_NAME|$CANONICAL_NAME|g" "$COMPONENT"
else
sed -i "s|$DUP_NAME|$CANONICAL_NAME|g" "$COMPONENT"
fi
fi
done
# Remove the duplicate file
rm "$FILE"
DEDUP_COUNT=$((DEDUP_COUNT + 1))
done
done
echo "" >&2
echo "Merged $DEDUP_COUNT duplicate assets" >&2
#!/usr/bin/env bash
#
# save-component-metadata.sh
#
# Save or update component metadata. Links component name to nodeId metadata.
#
# Usage:
# ./save-component-metadata.sh <component-name> <node-id> [component-path]
# ./save-component-metadata.sh <component-name> <node-id> <width> <height> [component-path]
#
# Arguments:
# component-name - Component name (e.g., LoginScreen)
# node-id - Figma node ID (e.g., 237:2571)
# width - Frame width in pixels (optional if hook already saved it)
# height - Frame height in pixels (optional if hook already saved it)
# component-path - Optional path to component file
#
# Output:
# Creates/updates /tmp/figma-to-react/metadata/{ComponentName}.json
#
# Example:
# ./save-component-metadata.sh LoginScreen "237:2571" # Link to existing nodeId
# ./save-component-metadata.sh LoginScreen "237:2571" 390 844 src/components/LoginScreen.tsx
set -e
COMPONENT_NAME="$1"
NODE_ID="$2"
if [ -z "$COMPONENT_NAME" ] || [ -z "$NODE_ID" ]; then
echo "Usage: $0 <component-name> <node-id> [component-path]" >&2
echo " $0 <component-name> <node-id> <width> <height> [component-path]" >&2
exit 1
fi
# Check if width/height provided or if we're just linking
if [[ "$3" =~ ^[0-9]+$ ]] && [[ "$4" =~ ^[0-9]+$ ]]; then
WIDTH="$3"
HEIGHT="$4"
COMPONENT_PATH="${5:-}"
else
WIDTH=""
HEIGHT=""
COMPONENT_PATH="${3:-}"
fi
METADATA_DIR="/tmp/figma-to-react/metadata"
mkdir -p "$METADATA_DIR"
# Sanitize node ID for filename (replace : with -)
SAFE_NODE_ID="${NODE_ID//:/-}"
NODE_FILE="${METADATA_DIR}/${SAFE_NODE_ID}.json"
COMPONENT_FILE="${METADATA_DIR}/${COMPONENT_NAME}.json"
if [ -n "$WIDTH" ] && [ -n "$HEIGHT" ]; then
# Full entry with dimensions provided
cat > "$COMPONENT_FILE" << EOF
{"nodeId": "$NODE_ID", "width": $WIDTH, "height": $HEIGHT, "name": "$COMPONENT_NAME", "componentPath": "$COMPONENT_PATH"}
EOF
echo "Saved metadata for $COMPONENT_NAME: ${WIDTH}x${HEIGHT}" >&2
else
# Link to existing nodeId file
if [ -f "$NODE_FILE" ]; then
if ! command -v jq >/dev/null 2>&1; then
echo "Error: jq is required to read node metadata. Install jq and try again." >&2
exit 1
fi
# Read existing and add component info
EXISTING=$(cat "$NODE_FILE")
WIDTH=$(echo "$EXISTING" | jq -r '.width')
HEIGHT=$(echo "$EXISTING" | jq -r '.height')
cat > "$COMPONENT_FILE" << EOF
{"nodeId": "$NODE_ID", "width": $WIDTH, "height": $HEIGHT, "name": "$COMPONENT_NAME", "componentPath": "$COMPONENT_PATH"}
EOF
echo "Linked $COMPONENT_NAME to nodeId $NODE_ID: ${WIDTH}x${HEIGHT}" >&2
else
echo "Warning: No existing entry for nodeId $NODE_ID at $NODE_FILE" >&2
exit 1
fi
fi
echo "$COMPONENT_FILE"
#!/bin/bash
#
# status.sh
#
# Report pipeline status by examining /tmp files.
# Used to determine current step and recover after context compaction.
#
# Usage:
# ./status.sh - Output JSON status
# ./status.sh --check N - Verify current step is N, exit 0 if yes, 1 if no
#
# Output:
# Default: JSON with current step, progress, and next action
# --check: Exit code 0 if on expected step, 1 if not (prints correct step)
#
# The agent should run this:
# - At skill start
# - After each step completes
# - When resuming after context compaction
#
set -e
BASE="/tmp/figma-to-react"
CHECK_MODE=""
EXPECTED_STEP=""
# Parse arguments
if [ "$1" = "--check" ]; then
CHECK_MODE="true"
EXPECTED_STEP="$2"
if [ -z "$EXPECTED_STEP" ]; then
echo "Usage: $0 --check <step>" >&2
exit 2
fi
fi
# Check if skill is active
if [ ! -f "$BASE/capture-active" ]; then
STEP="1"
NEXT="Run step 1 setup to start"
if [ "$CHECK_MODE" = "true" ]; then
if [ "$STEP" = "$EXPECTED_STEP" ]; then
echo "OK: on step $STEP"
exit 0
else
echo "WRONG STEP: expected $EXPECTED_STEP but on $STEP"
echo "Action: $NEXT"
exit 1
fi
fi
cat << 'EOF'
{
"active": false,
"current_step": "1",
"next_action": "Run step 1 setup to start"
}
EOF
exit 0
fi
# Read config
if [ ! -f "$BASE/config.json" ]; then
STEP="3"
NEXT="Save config.json"
if [ "$CHECK_MODE" = "true" ]; then
if [ "$STEP" = "$EXPECTED_STEP" ]; then
echo "OK: on step $STEP"
exit 0
else
echo "WRONG STEP: expected $EXPECTED_STEP but on $STEP"
echo "Action: $NEXT"
exit 1
fi
fi
cat << 'EOF'
{
"active": true,
"current_step": "3",
"next_action": "Save config.json"
}
EOF
exit 0
fi
# Get expected screen count from config
SCREENS=0
if [ -f "$BASE/config.json" ]; then
SCREENS=$(jq -r '.screens | length' "$BASE/config.json" 2>/dev/null || echo 0)
fi
# Fallback: count lines in input.txt
if [ "$SCREENS" -eq 0 ] && [ -f "$BASE/input.txt" ]; then
SCREENS=$(wc -l < "$BASE/input.txt" | tr -d ' ')
fi
# Get paths from config
COMPONENT_DIR=$(jq -r '.componentDir // "src/components/figma"' "$BASE/config.json" 2>/dev/null)
TOKENS_FILE=$(jq -r '.tokensFile // "src/styles/figma-tokens.css"' "$BASE/config.json" 2>/dev/null)
# Detect main CSS file (for checking token import)
MAIN_CSS=""
for candidate in src/index.css src/App.css src/styles/index.css src/app/globals.css; do
if [ -f "$candidate" ]; then
MAIN_CSS="$candidate"
break
fi
done
# Count progress at each stage
CAPTURES=$(ls "$BASE/captures/figma-"*.txt 2>/dev/null | wc -l | tr -d ' ')
COMPONENTS=$(ls "$COMPONENT_DIR/"*.tsx 2>/dev/null | wc -l | tr -d ' ')
# Count step 4b dimension validations (exclude user-decisions.json and complete.json)
DIM_VALS=0
if [ -d "$BASE/steps/4b" ]; then
DIM_VALS=$(ls "$BASE/steps/4b/"*.json 2>/dev/null | grep -v 'user-decisions\|complete' | wc -l | tr -d ' ')
fi
# Check step 4b user decision status
MISSING_DIMS=0
if [ "$DIM_VALS" -gt 0 ]; then
# Sum critical_missing from all validation JSONs
MISSING_DIMS=$(jq -s 'map(.critical_missing // 0) | add // 0' "$BASE/steps/4b/"*.json 2>/dev/null | grep -v 'user-decisions\|complete' || echo 0)
# Ensure numeric
MISSING_DIMS=${MISSING_DIMS:-0}
[[ "$MISSING_DIMS" =~ ^[0-9]+$ ]] || MISSING_DIMS=0
fi
# Check if user addressed ALL missing dimensions (not just some)
USER_DECIDED="false"
if [ -f "$BASE/steps/4b/user-decisions.json" ]; then
ADDRESSED=$(jq '.addressed_ids | length' "$BASE/steps/4b/user-decisions.json" 2>/dev/null || echo 0)
if [ "$ADDRESSED" -ge "$MISSING_DIMS" ]; then
USER_DECIDED="true"
fi
fi
# Check step 5 (token import)
TOKEN_IMPORTED="false"
if [ -f "$TOKENS_FILE" ]; then
# Check if import exists in main CSS
if [ -n "$MAIN_CSS" ] && grep -q 'figma-tokens.css' "$MAIN_CSS" 2>/dev/null; then
TOKEN_IMPORTED="true"
fi
# Also check step completion marker
if [ -f "$BASE/steps/5/complete.json" ]; then
TOKEN_IMPORTED="true"
fi
fi
# Check step 6 (visual validation)
VIS_DONE=0
VIS_PENDING=0
for comp in "$COMPONENT_DIR"/*.tsx; do
[ -f "$comp" ] || continue
NAME=$(basename "$comp" .tsx)
RESULT="$BASE/validation/$NAME/result.json"
if [ -f "$RESULT" ]; then
STATUS=$(jq -r '.status' "$RESULT" 2>/dev/null)
if [ "$STATUS" = "success" ] || [ "$STATUS" = "good_enough" ] || [ "$STATUS" = "max_passes" ]; then
VIS_DONE=$((VIS_DONE + 1))
else
VIS_PENDING=$((VIS_PENDING + 1))
fi
else
VIS_PENDING=$((VIS_PENDING + 1))
fi
done
# Check step 7 (asset rename)
ASSETS_RENAMED="false"
if [ -f "$BASE/steps/7/complete.json" ]; then
ASSETS_RENAMED="true"
fi
# Determine current step and next action
if [ "$COMPONENTS" -lt "$SCREENS" ]; then
STEP="4"
MISSING=$((SCREENS - COMPONENTS))
NEXT="Generate $MISSING remaining components (have $COMPONENTS of $SCREENS)"
elif [ "$DIM_VALS" -lt "$SCREENS" ]; then
STEP="4b"
MISSING=$((SCREENS - DIM_VALS))
NEXT="Run validate-dimensions-coverage.sh for $MISSING screens"
elif [ "$MISSING_DIMS" -gt 0 ] && [ "$USER_DECIDED" = "false" ]; then
STEP="4b"
if [ -f "$BASE/steps/4b/user-decisions.json" ]; then
ADDRESSED=$(jq '.addressed_ids | length' "$BASE/steps/4b/user-decisions.json" 2>/dev/null || echo 0)
REMAINING=$((MISSING_DIMS - ADDRESSED))
NEXT="Ask user about $REMAINING remaining missing dimensions ($ADDRESSED of $MISSING_DIMS addressed)"
else
NEXT="Ask user about $MISSING_DIMS missing dimensions"
fi
elif [ "$TOKEN_IMPORTED" = "false" ]; then
STEP="5"
NEXT="Add @import for figma-tokens.css to main stylesheet"
elif [ "$VIS_PENDING" -gt 0 ]; then
STEP="6"
NEXT="Validate $VIS_PENDING components visually ($VIS_DONE done)"
elif [ "$ASSETS_RENAMED" = "false" ]; then
STEP="7"
NEXT="Check for generic asset names, offer to rename"
else
STEP="8"
NEXT="Disarm hook and verify results"
fi
# Handle --check mode
if [ "$CHECK_MODE" = "true" ]; then
if [ "$STEP" = "$EXPECTED_STEP" ]; then
echo "OK: on step $STEP"
exit 0
else
echo "WRONG STEP: expected $EXPECTED_STEP but on $STEP"
echo "Action: $NEXT"
exit 1
fi
fi
# Default: output JSON status
cat << EOF
{
"active": true,
"current_step": "$STEP",
"total_screens": $SCREENS,
"progress": {
"captures": $CAPTURES,
"components": $COMPONENTS,
"dim_validations": $DIM_VALS,
"missing_dimensions": $MISSING_DIMS,
"user_decisions": $USER_DECIDED,
"token_imported": $TOKEN_IMPORTED,
"vis_validated": $VIS_DONE,
"vis_pending": $VIS_PENDING,
"assets_renamed": $ASSETS_RENAMED
},
"next_action": "$NEXT"
}
EOF
#!/usr/bin/env bash
#
# validate-component.sh
#
# Run one validation pass for a component. Deterministic - no LLM logic.
# Returns status code indicating what to do next.
#
# Usage:
# ./validate-component.sh <component> <figma-png> <preview-url> <component-path> [prev-diff]
#
# Arguments:
# component - Component name (e.g., LoginScreen)
# figma-png - Path to Figma reference screenshot
# preview-url - URL to capture
# component-path - Path to component file (for revert on no improvement)
# prev-diff - Previous diff % (optional, for detecting improvement)
#
# Exit codes:
# 0 - Success (diff ≤ 5%)
# 1 - Needs fix (first pass or improved, but still > 5%)
# 2 - Good enough (diff ≤ 1%)
# 5 - Max passes reached (10)
# 6 - No improvement (reverted to last good state, try something DIFFERENT)
# 10 - Error
#
# Output (JSON to stdout):
# { "status": "...", "diff": 4.23, "diff_image": "...", "message": "..." }
set -e
COMPONENT="$1"
FIGMA_PNG="$2"
PREVIEW_URL="$3"
COMPONENT_PATH="$4"
PREV_DIFF="$5"
TARGET=5
GOOD_ENOUGH=1
MAX_PASSES=10
SKILL_DIR="${SKILL_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
error_exit() {
echo "{\"status\": \"error\", \"message\": \"$1\"}"
exit 10
}
[ -z "$COMPONENT" ] || [ -z "$FIGMA_PNG" ] || [ -z "$PREVIEW_URL" ] || [ -z "$COMPONENT_PATH" ] && \
error_exit "Usage: $0 <component> <figma-png> <preview-url> <component-path> [prev-diff]"
[ ! -f "$FIGMA_PNG" ] && error_exit "Figma screenshot not found: $FIGMA_PNG"
[ ! -f "$COMPONENT_PATH" ] && error_exit "Component file not found: $COMPONENT_PATH"
[ -z "$(command -v bun)" ] && error_exit "bun is required for screenshot capture. Install from https://bun.sh"
# Validation directory and state
VALIDATION_DIR="/tmp/figma-to-react/validation/${COMPONENT}"
KNOWN_GOOD="${VALIDATION_DIR}/.known-good-component"
mkdir -p "$VALIDATION_DIR"
# Determine pass number from existing directories
PASS=$(ls -d "${VALIDATION_DIR}"/pass-* 2>/dev/null | wc -l | tr -d ' ')
PASS=$((PASS + 1))
# Check max passes
if [ "$PASS" -gt "$MAX_PASSES" ]; then
echo "{\"status\": \"max_passes\", \"pass\": $PASS, \"message\": \"Max passes ($MAX_PASSES) reached\"}"
exit 5
fi
PASS_DIR="${VALIDATION_DIR}/pass-${PASS}"
mkdir -p "$PASS_DIR"
RENDERED_PNG="${PASS_DIR}/rendered.png"
# Capture (bun resolves modules from cwd, unlike tsx which uses script location)
bun "${SKILL_DIR}/scripts/capture-screenshot.ts" "$PREVIEW_URL" "$RENDERED_PNG" 2>/dev/null \
|| error_exit "Capture failed"
[ ! -f "$RENDERED_PNG" ] && error_exit "Screenshot not created"
# Validate (just get diff %, don't let it create dirs - we already did)
DIFF=$("${SKILL_DIR}/scripts/validate-visual.sh" "$FIGMA_PNG" "$RENDERED_PNG" "$COMPONENT" "$PASS" 2>/dev/null) \
|| error_exit "Validation failed"
DIFF_IMAGE="${PASS_DIR}/diff.png"
# Determine status
if (( $(echo "$DIFF <= $GOOD_ENOUGH" | bc -l) )); then
STATUS="good_enough"
MSG="Pass $PASS: ${DIFF}% (≤${GOOD_ENOUGH}%, done)"
EXIT=2
# Save as known good
cp "$COMPONENT_PATH" "$KNOWN_GOOD"
elif (( $(echo "$DIFF <= $TARGET" | bc -l) )); then
STATUS="success"
MSG="Pass $PASS: ${DIFF}% (≤${TARGET}%, done)"
EXIT=0
# Save as known good
cp "$COMPONENT_PATH" "$KNOWN_GOOD"
elif [ -n "$PREV_DIFF" ]; then
# Check if we improved
IMPROVED=$(echo "$DIFF < $PREV_DIFF" | bc -l)
if [ "$IMPROVED" -eq 1 ]; then
DELTA=$(echo "$PREV_DIFF - $DIFF" | bc -l | xargs printf "%.2f")
STATUS="needs_fix"
MSG="Pass $PASS: ${DIFF}% (improved ${DELTA}% from ${PREV_DIFF}%)"
EXIT=1
# Save as known good (we improved)
cp "$COMPONENT_PATH" "$KNOWN_GOOD"
else
# No improvement - revert to last known good state
if [ -f "$KNOWN_GOOD" ]; then
cp "$KNOWN_GOOD" "$COMPONENT_PATH"
STATUS="no_improvement"
MSG="Pass $PASS: ${DIFF}% (was ${PREV_DIFF}%, reverted - try DIFFERENT fix)"
else
STATUS="no_improvement"
MSG="Pass $PASS: ${DIFF}% (was ${PREV_DIFF}%, no baseline to revert - try DIFFERENT fix)"
fi
EXIT=6
fi
else
# First pass - save as baseline
STATUS="needs_fix"
MSG="Pass $PASS: ${DIFF}% (target: ≤${TARGET}%)"
EXIT=1
cp "$COMPONENT_PATH" "$KNOWN_GOOD"
fi
cat <<EOF
{
"status": "$STATUS",
"pass": $PASS,
"diff": $DIFF,
"prev_diff": ${PREV_DIFF:-null},
"diff_image": "$DIFF_IMAGE",
"message": "$MSG"
}
EOF
exit $EXIT
#!/usr/bin/env bash
#
# validate-dimensions-coverage.sh
#
# Validates that all data-node-id values in the TSX have corresponding
# dimensions in the dimensions JSON. Reports missing ones that have
# collapse-prone patterns (padding + positioning).
#
# Usage:
# ./validate-dimensions-coverage.sh <tsx-file> <dimensions-json> [--all]
# ./validate-dimensions-coverage.sh <captures-dir> <metadata-dir> [--all]
# ./validate-dimensions-coverage.sh <tsx-file1> <tsx-file2> ... <metadata-dir> [--all]
#
# Arguments:
# tsx-file - Path to generated TSX file (or capture txt)
# dimensions-json - Path to dimensions JSON map
# captures-dir - Directory containing figma-*.txt files
# metadata-dir - Directory containing *-dimensions.json files
# --all - Report ALL missing IDs, not just collapse-prone ones
#
# Examples:
# # Single file
# ./validate-dimensions-coverage.sh captures/figma-237-2571.txt metadata/237-2571-dimensions.json
#
# # All files in directories
# ./validate-dimensions-coverage.sh captures/ metadata/
#
# # Multiple specific files
# ./validate-dimensions-coverage.sh captures/figma-237-2571.txt captures/figma-237-2416.txt metadata/
#
# Output:
# - JSON with missing node IDs that need dimensions (per file or combined)
# - Human-readable summary to stderr
#
# Exit codes:
# 0 - All collapse-prone node IDs have dimensions
# 1 - Missing dimensions for collapse-prone nodes
set -e
# Parse arguments - check for --all flag
REPORT_ALL=""
ARGS=()
for arg in "$@"; do
if [ "$arg" = "--all" ]; then
REPORT_ALL="--all"
else
ARGS+=("$arg")
fi
done
if [ ${#ARGS[@]} -lt 2 ]; then
echo "Usage: $0 <tsx-file|captures-dir> <dimensions-json|metadata-dir> [--all]" >&2
echo " $0 <tsx1> <tsx2> ... <metadata-dir> [--all]" >&2
exit 1
fi
# Determine mode: single file, directory, or multi-file
LAST_ARG="${ARGS[-1]}"
FIRST_ARG="${ARGS[0]}"
# Function to extract node ID from filename (e.g., figma-237-2571.txt -> 237-2571)
extract_node_id() {
basename "$1" | sed -E 's/^figma-//; s/\.(txt|tsx)$//'
}
# Function to find matching dimensions JSON for a tsx/txt file
find_dimensions_json() {
local tsx_file="$1"
local metadata_dir="$2"
local node_id=$(extract_node_id "$tsx_file")
local dim_file="$metadata_dir/${node_id}-dimensions.json"
if [ -f "$dim_file" ]; then
echo "$dim_file"
fi
}
# Collect file pairs to process
declare -a TSX_FILES
declare -a DIM_FILES
if [ -d "$FIRST_ARG" ] && [ -d "$LAST_ARG" ]; then
# Both are directories - match by node ID
CAPTURES_DIR="$FIRST_ARG"
METADATA_DIR="$LAST_ARG"
for tsx in "$CAPTURES_DIR"/figma-*.txt; do
[ -f "$tsx" ] || continue
dim=$(find_dimensions_json "$tsx" "$METADATA_DIR")
if [ -n "$dim" ]; then
TSX_FILES+=("$tsx")
DIM_FILES+=("$dim")
else
echo "Warning: No dimensions file for $(basename "$tsx")" >&2
fi
done
elif [ -d "$LAST_ARG" ]; then
# Last arg is directory, rest are files
METADATA_DIR="$LAST_ARG"
for ((i=0; i<${#ARGS[@]}-1; i++)); do
tsx="${ARGS[$i]}"
if [ ! -f "$tsx" ]; then
echo "Error: File not found: $tsx" >&2
exit 1
fi
dim=$(find_dimensions_json "$tsx" "$METADATA_DIR")
if [ -n "$dim" ]; then
TSX_FILES+=("$tsx")
DIM_FILES+=("$dim")
else
echo "Warning: No dimensions file for $(basename "$tsx")" >&2
fi
done
else
# Single file pair (original behavior)
TSX_FILES=("$FIRST_ARG")
DIM_FILES=("$LAST_ARG")
fi
if [ ${#TSX_FILES[@]} -eq 0 ]; then
echo "Error: No matching file pairs found" >&2
exit 1
fi
# Validate files exist
for tsx in "${TSX_FILES[@]}"; do
if [ ! -f "$tsx" ]; then
echo "Error: TSX file not found: $tsx" >&2
exit 1
fi
done
for dim in "${DIM_FILES[@]}"; do
if [ ! -f "$dim" ]; then
echo "Error: Dimensions JSON not found: $dim" >&2
exit 1
fi
done
if ! command -v jq &>/dev/null; then
echo "Error: jq is required" >&2
exit 1
fi
# Helper: check if a line has collapse pattern (padding + positioning, no explicit dimension)
has_collapse_pattern() {
local line="$1"
# Must have positioning
if ! echo "$line" | grep -qE 'relative|absolute'; then
return 1
fi
# Must have padding without explicit dimension
# Use word boundaries to avoid matching top-[, gap-[, etc.
# Padding classes: p-[...], py-[...], px-[...], pt-[...], pb-[...], pl-[...], pr-[...]
if echo "$line" | grep -qE '(^|[" ])p[ytblrx]?-\[' && ! echo "$line" | grep -qE 'h-\[[0-9]+px\]|h-full|size-full'; then
return 0
fi
if echo "$line" | grep -qE '(^|[" ])p[xlr]?-\[' && ! echo "$line" | grep -qE 'w-\[[0-9]+px\]|w-full|size-full'; then
return 0
fi
return 1
}
# Process a single file pair and output JSON fragment
process_file_pair() {
local tsx_file="$1"
local dim_json="$2"
local file_idx="$3"
# Extract all node IDs from dimensions.json
local DIM_NODE_IDS=$(jq -r 'keys[]' "$dim_json" 2>/dev/null | sort -u)
# Find missing node IDs
local -a MISSING=()
local -a MISSING_CRITICAL=()
local -a ALL_TSX_IDS=()
local -A NODE_NAMES=()
while IFS= read -r line; do
# Extract node ID from line
local node_id=$(echo "$line" | grep -oE 'data-node-id="[^"]+"' | sed 's/data-node-id="//;s/"//')
[ -z "$node_id" ] && continue
# Extract component/element name if present
local node_name=$(echo "$line" | grep -oE 'data-name="[^"]+"' | sed 's/data-name="//;s/"//')
if [ -z "$node_name" ]; then
# Try to get component name from tag like <ComponentName
node_name=$(echo "$line" | grep -oE '<[A-Z][A-Za-z0-9_]+' | head -1 | tr -d '<')
fi
[ -n "$node_name" ] && NODE_NAMES["$node_id"]="$node_name"
ALL_TSX_IDS+=("$node_id")
# Check if dimensions exist
if ! echo "$DIM_NODE_IDS" | grep -qx "$node_id"; then
MISSING+=("$node_id")
# Check if this is a collapse-prone node
if has_collapse_pattern "$line"; then
MISSING_CRITICAL+=("$node_id")
fi
fi
done < "$tsx_file"
# Deduplicate
MISSING=($(printf '%s\n' "${MISSING[@]}" | sort -u))
MISSING_CRITICAL=($(printf '%s\n' "${MISSING_CRITICAL[@]}" | sort -u))
ALL_TSX_IDS=($(printf '%s\n' "${ALL_TSX_IDS[@]}" | sort -u))
local TSX_COUNT=${#ALL_TSX_IDS[@]}
local DIM_COUNT=$(echo "$DIM_NODE_IDS" | wc -l | tr -d ' ')
local MISSING_COUNT=${#MISSING[@]}
local CRITICAL_COUNT=${#MISSING_CRITICAL[@]}
echo "=== $(basename "$tsx_file") ===" >&2
echo " TSX node IDs: $TSX_COUNT" >&2
echo " Dimensions entries: $DIM_COUNT" >&2
echo " Missing dimensions: $MISSING_COUNT" >&2
echo " Missing (collapse-prone): $CRITICAL_COUNT" >&2
# Decide which list to report
local -a REPORT_LIST
local REPORT_COUNT
if [ "$REPORT_ALL" = "--all" ]; then
REPORT_LIST=("${MISSING[@]}")
REPORT_COUNT=$MISSING_COUNT
else
REPORT_LIST=("${MISSING_CRITICAL[@]}")
REPORT_COUNT=$CRITICAL_COUNT
fi
if [ $REPORT_COUNT -eq 0 ]; then
if [ $MISSING_COUNT -eq 0 ]; then
echo " ✓ All node IDs have dimensions" >&2
else
echo " ✓ All collapse-prone nodes have dimensions ($MISSING_COUNT non-critical missing)" >&2
fi
else
echo " ⚠ Missing dimensions for collapse-prone nodes:" >&2
for node_id in "${REPORT_LIST[@]}"; do
local name="${NODE_NAMES[$node_id]}"
if [ -n "$name" ]; then
echo " - $name ($node_id)" >&2
else
echo " - $node_id" >&2
fi
done
fi
echo "" >&2
# Output JSON for this file
echo " {"
echo " \"tsx_file\": \"$tsx_file\","
echo " \"dimensions_file\": \"$dim_json\","
echo " \"missing\": ["
for i in "${!REPORT_LIST[@]}"; do
local node_id="${REPORT_LIST[$i]}"
local name="${NODE_NAMES[$node_id]}"
if [ $i -eq $((REPORT_COUNT - 1)) ]; then
echo " {\"id\": \"$node_id\", \"name\": \"${name:-unknown}\"}"
else
echo " {\"id\": \"$node_id\", \"name\": \"${name:-unknown}\"},"
fi
done
echo " ],"
echo " \"total_missing\": $MISSING_COUNT,"
echo " \"critical_missing\": $CRITICAL_COUNT"
echo " }"
# Return counts for exit code calculation
echo "$CRITICAL_COUNT $REPORT_COUNT" > /tmp/validate-dims-critical-$$-$file_idx
}
# Main processing
TOTAL_CRITICAL=0
TOTAL_REPORTED=0
FILE_COUNT=${#TSX_FILES[@]}
echo "=== Dimensions Coverage Validation ===" >&2
echo "Processing $FILE_COUNT file(s)..." >&2
echo "" >&2
# Collect JSON output in array to handle commas properly
declare -a JSON_PARTS
for i in "${!TSX_FILES[@]}"; do
# Capture JSON output from function
JSON_PART=$(process_file_pair "${TSX_FILES[$i]}" "${DIM_FILES[$i]}" "$i")
JSON_PARTS+=("$JSON_PART")
# Accumulate counts
if [ -f "/tmp/validate-dims-critical-$$-$i" ]; then
read CRITICAL REPORTED < "/tmp/validate-dims-critical-$$-$i"
TOTAL_CRITICAL=$((TOTAL_CRITICAL + CRITICAL))
TOTAL_REPORTED=$((TOTAL_REPORTED + REPORTED))
rm -f "/tmp/validate-dims-critical-$$-$i"
fi
done
# Output combined JSON
echo "{"
echo " \"files\": ["
for i in "${!JSON_PARTS[@]}"; do
echo "${JSON_PARTS[$i]}"
if [ $i -lt $((FILE_COUNT - 1)) ]; then
echo " ,"
fi
done
echo " ],"
echo " \"total_files\": $FILE_COUNT,"
echo " \"total_critical_missing\": $TOTAL_CRITICAL,"
echo " \"total_reported_missing\": $TOTAL_REPORTED"
echo "}"
# Summary
echo "=== Summary ===" >&2
echo "Files processed: $FILE_COUNT" >&2
echo "Total critical missing: $TOTAL_CRITICAL" >&2
if [ "$REPORT_ALL" = "--all" ]; then
echo "Total reported missing: $TOTAL_REPORTED" >&2
fi
# Exit code: with --all, exit 1 if any reported; otherwise exit 1 if any critical
if [ "$REPORT_ALL" = "--all" ]; then
if [ $TOTAL_REPORTED -eq 0 ]; then
echo "✓ All node IDs have dimensions" >&2
exit 0
else
echo "⚠ Some files have missing dimensions" >&2
exit 1
fi
else
if [ $TOTAL_CRITICAL -eq 0 ]; then
echo "✓ All collapse-prone nodes have dimensions" >&2
exit 0
else
echo "⚠ Some files have missing dimensions" >&2
exit 1
fi
fi
#!/usr/bin/env bash
#
# validate-visual.sh
#
# Compare a Figma screenshot against a rendered component screenshot.
# Uses ImageMagick to compute similarity and output diff percentage.
#
# Usage:
# ./validate-visual.sh <figma-screenshot> <rendered-screenshot> [component] [pass]
#
# Arguments:
# figma-screenshot - Path to Figma reference image
# rendered-screenshot - Path to rendered component screenshot
# component - Component name (optional, defaults to timestamp)
# pass - Pass number (optional, defaults to 1)
#
# Output:
# - Creates /tmp/figma-to-react/validation/{component}/ containing:
# - figma.png - Figma reference (copied once)
# - pass-{N}/
# - rendered.png - Rendered component screenshot
# - diff.png - Heatmap (brighter = more different)
# - Prints diff percentage to stdout
# - Exit code: 0 = success, 1 = error
#
# Example:
# ./validate-visual.sh /tmp/figma.png /tmp/rendered.png LoginScreen 2
# # Output: 3.45
set -e
FIGMA_IMG="$1"
RENDERED_IMG="$2"
COMPONENT="${3:-$(date +%s)}"
PASS="${4:-1}"
if [ -z "$FIGMA_IMG" ] || [ -z "$RENDERED_IMG" ]; then
echo "Usage: $0 <figma-screenshot> <rendered-screenshot> [component] [pass]" >&2
echo "" >&2
echo "Arguments:" >&2
echo " figma-screenshot - Path to Figma reference image" >&2
echo " rendered-screenshot - Path to rendered component screenshot" >&2
echo " component - Component name (optional)" >&2
echo " pass - Pass number (optional, default: 1)" >&2
echo "" >&2
echo "Output: diff percentage (e.g., 3.45)" >&2
exit 1
fi
if [ ! -f "$FIGMA_IMG" ]; then
echo "Error: Figma screenshot not found: $FIGMA_IMG" >&2
exit 1
fi
if [ ! -f "$RENDERED_IMG" ]; then
echo "Error: Rendered screenshot not found: $RENDERED_IMG" >&2
exit 1
fi
# Check for ImageMagick
if ! command -v magick &> /dev/null; then
echo "Error: ImageMagick not found. Install with: brew install imagemagick" >&2
exit 1
fi
# Create output directories
VALIDATION_DIR="/tmp/figma-to-react/validation/${COMPONENT}"
PASS_DIR="${VALIDATION_DIR}/pass-${PASS}"
mkdir -p "$PASS_DIR"
# Output paths
FIGMA_COPY="${VALIDATION_DIR}/figma.png"
RENDERED_COPY="${PASS_DIR}/rendered.png"
DIFF_IMG="${PASS_DIR}/diff.png"
RESIZED_FIGMA="${PASS_DIR}/.figma-resized.png"
# Get dimensions
FIGMA_SIZE=$(magick identify -format "%wx%h" "$FIGMA_IMG")
RENDERED_SIZE=$(magick identify -format "%wx%h" "$RENDERED_IMG")
echo "Figma size: $FIGMA_SIZE" >&2
echo "Rendered size: $RENDERED_SIZE" >&2
# Copy rendered to output dir (skip if same file)
if [ "$(realpath "$RENDERED_IMG")" != "$(realpath "$RENDERED_COPY" 2>/dev/null)" ]; then
cp "$RENDERED_IMG" "$RENDERED_COPY"
fi
# Extract dimensions
FIGMA_W=$(echo "$FIGMA_SIZE" | cut -dx -f1)
FIGMA_H=$(echo "$FIGMA_SIZE" | cut -dx -f2)
RENDERED_W=$(echo "$RENDERED_SIZE" | cut -dx -f1)
RENDERED_H=$(echo "$RENDERED_SIZE" | cut -dx -f2)
# Check if sizes match (they should with element-level screenshots)
if [ "$FIGMA_SIZE" = "$RENDERED_SIZE" ]; then
echo "Dimensions match: $FIGMA_SIZE (good)" >&2
COMPARE_IMG="$FIGMA_IMG"
else
# Check for fixed multiples (2x, 3x retina)
W_RATIO=$((RENDERED_W / FIGMA_W))
H_RATIO=$((RENDERED_H / FIGMA_H))
W_MOD=$((RENDERED_W % FIGMA_W))
H_MOD=$((RENDERED_H % FIGMA_H))
# >= 2 to exclude 1x (which means sizes match, handled above)
if [ "$W_RATIO" = "$H_RATIO" ] && [ "$W_MOD" -eq 0 ] && [ "$H_MOD" -eq 0 ] && [ "$W_RATIO" -ge 2 ]; then
echo "Detected ${W_RATIO}x retina scaling" >&2
echo " Figma: $FIGMA_SIZE (1x)" >&2
echo " Rendered: $RENDERED_SIZE (${W_RATIO}x)" >&2
echo " Upscaling Figma ${W_RATIO}x for comparison..." >&2
magick "$FIGMA_IMG" -resize "$((W_RATIO * 100))%" "$RESIZED_FIGMA"
COMPARE_IMG="$RESIZED_FIGMA"
else
echo "WARNING: Dimension mismatch! This may indicate a rendering issue." >&2
echo " Expected: $FIGMA_SIZE (Figma)" >&2
echo " Got: $RENDERED_SIZE (rendered)" >&2
echo " Resizing Figma to match for comparison..." >&2
magick "$FIGMA_IMG" -resize "${RENDERED_SIZE}!" "$RESIZED_FIGMA"
COMPARE_IMG="$RESIZED_FIGMA"
fi
fi
# Copy Figma reference once (at component level, not per-pass)
if [ ! -f "$FIGMA_COPY" ]; then
cp "$FIGMA_IMG" "$FIGMA_COPY"
echo "Saved Figma reference: $FIGMA_COPY" >&2
fi
# Compare images
echo "Computing visual similarity..." >&2
# Normalize both to RGB (remove alpha) for fair comparison
NORM_FIGMA="${PASS_DIR}/.figma-norm.png"
NORM_RENDERED="${PASS_DIR}/.rendered-norm.png"
magick "$COMPARE_IMG" -alpha off "$NORM_FIGMA"
magick "$RENDERED_IMG" -alpha off "$NORM_RENDERED"
# Create heatmap diff (brighter = more different)
# No auto-level so differences remain proportional to actual magnitude
magick "$NORM_FIGMA" "$NORM_RENDERED" \
-compose difference -composite \
-grayscale Rec709Luminance \
-colorspace sRGB \
"$DIFF_IMG"
# Get RMSE metric for pass/fail calculation
RESULT=$(magick compare -metric RMSE "$NORM_FIGMA" "$NORM_RENDERED" null: 2>&1 || true)
# Clean up temp files
rm -f "$NORM_FIGMA" "$NORM_RENDERED"
# Extract the normalized value (in parentheses)
NORMALIZED=$(echo "$RESULT" | grep -oE '\([0-9.]+\)' | tr -d '()')
if [ -z "$NORMALIZED" ]; then
echo "Error: Could not parse comparison result: $RESULT" >&2
exit 1
fi
# Convert to percentage (RMSE is 0-1, multiply by 100)
DIFF_PERCENT=$(echo "$NORMALIZED * 100" | bc -l | xargs printf "%.2f")
echo "" >&2
echo "Pass $PASS: $PASS_DIR" >&2
echo " rendered.png - Rendered component" >&2
echo " diff.png - Heatmap (brighter = more different)" >&2
echo "Reference: $FIGMA_COPY" >&2
echo "" >&2
# Output just the percentage to stdout
echo "$DIFF_PERCENT"
import '../index.css';
import { ComponentType, StrictMode, Suspense, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
// Auto-discover all components in the figma directory
const modules = import.meta.glob<{
default?: ComponentType;
figmaDimensions?: { width: number; height: number };
}>('../components/figma/*.tsx');
// Build component name → loader map
const loaders: Record<string, () => Promise<any>> = {};
for (const [path, loader] of Object.entries(modules)) {
const name = path.match(/\/([^/]+)\.tsx$/)?.[1] || path;
loaders[name] = loader;
}
function FigmaPreview() {
const params = new URLSearchParams(window.location.search);
const screenName = params.get('screen');
const [Component, setComponent] = useState<ComponentType | null>(null);
const [dim, setDim] = useState({ width: 400, height: 800 });
useEffect(() => {
if (!screenName || !loaders[screenName]) return;
loaders[screenName]().then(mod => {
setComponent(() => mod.default || mod[screenName]);
setDim(mod.figmaDimensions || { width: 400, height: 800 });
});
}, [screenName]);
if (!screenName) {
return (
<div style={{ padding: '2rem' }}>
<h1>Figma Preview</h1>
{Object.keys(loaders).length === 0 ? (
<p>No components yet. Run step 4 to generate components.</p>
) : (
<ul>
{Object.keys(loaders).map(name => (
<li key={name}>
<a href={`?screen=${name}`}>{name}</a>
</li>
))}
</ul>
)}
</div>
);
}
if (!Component) return <div>Loading {screenName}...</div>;
return (
<div
data-figma-component={screenName}
style={{ width: dim.width, height: dim.height, overflow: 'hidden' }}
>
<Suspense fallback={<div>Loading...</div>}>
<Component />
</Suspense>
</div>
);
}
createRoot(document.getElementById('figma-preview-root')!).render(
<StrictMode>
<FigmaPreview />
</StrictMode>
);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Figma Preview</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
</style>
</head>
<body>
<div id="figma-preview-root"></div>
<script type="module" src="/src/pages/figma-preview-entry.tsx"></script>
</body>
</html>
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { NextResponse } from 'next/server';
export async function GET() {
try {
// Read componentDir from config if available
const configPath = '/tmp/figma-to-react/config.json';
let componentDir = 'src/components/figma';
try {
const config = JSON.parse(readFileSync(configPath, 'utf8'));
componentDir = config.componentDir || componentDir;
} catch {
// Use default if config not found
}
const dir = join(process.cwd(), componentDir);
const files = readdirSync(dir).filter(f => f.endsWith('.tsx'));
const screens = files.map(f => f.replace('.tsx', ''));
return NextResponse.json({ screens });
} catch {
return NextResponse.json({ screens: [] });
}
}