diff --git a/.claude/agents/calibration/converter.md b/.claude/agents/calibration/converter.md index 4f888660..1f875be8 100644 --- a/.claude/agents/calibration/converter.md +++ b/.claude/agents/calibration/converter.md @@ -1,7 +1,7 @@ --- name: calibration-converter description: Converts the entire scoped Figma design to a single HTML page and measures pixel-perfect accuracy via visual comparison. -tools: Bash, Read, Write, Glob, mcp__figma__get_design_context +tools: Bash, Read, Write, Glob model: claude-sonnet-4-6 --- @@ -38,8 +38,6 @@ Read the original fixture JSON directly when you need to verify a value from the > **Rule: If design tree and fixture disagree, trust the fixture.** > The design tree is a compressed representation. The fixture JSON contains the authoritative raw values from Figma. -If the input is a Figma URL, call `get_design_context` MCP tool instead (no fixture JSON available in that case — use design context as the sole source). - ## Code Generation Prompt Read and follow `.claude/skills/design-to-code/PROMPT.md` for all code generation rules. Key points: @@ -50,7 +48,7 @@ Read and follow `.claude/skills/design-to-code/PROMPT.md` for all code generatio ## Steps 1. Read `docs/DESIGN-TO-CODE-PROMPT.md` for code generation rules -2. Generate design tree (CLI) or get design context (MCP) +2. Generate design tree (CLI) 3. Convert the design tree to a single standalone HTML+CSS file - Each node in the tree maps 1:1 to an HTML element - Copy style values directly — they are already CSS-ready diff --git a/.claude/commands/calibrate-loop-deep.md b/.claude/commands/calibrate-loop-deep.md deleted file mode 100644 index b0d03eff..00000000 --- a/.claude/commands/calibrate-loop-deep.md +++ /dev/null @@ -1,168 +0,0 @@ -Run a deep calibration debate loop using Figma MCP for precise design context. - -Input: $ARGUMENTS (Figma URL with node-id, e.g. `https://www.figma.com/design/ABC123/MyDesign?node-id=1-234`) - -## Instructions - -You are the orchestrator. Do NOT make calibration decisions yourself. Only pass data between agents and run deterministic CLI steps. - -**CRITICAL: You are responsible for writing ALL files to $RUN_DIR. Subagents return text/JSON — you write files. Never rely on a subagent to write to the correct path.** - -### Step 0 — Setup - -Extract a short name from the URL (fileKey or design name). Create the run directory: - -``` -RUN_DIR=logs/calibration/--/ -mkdir -p $RUN_DIR -``` - -Create `$RUN_DIR/activity.jsonl` and write the first JSON Lines entry: - -```json -{"step":"session-start","timestamp":"","result":"Calibration activity log initialized","durationMs":0} -``` - -Store the exact `RUN_DIR` path — you will paste it verbatim into every subagent prompt below. - -### Step 1 — Analysis (CLI) - -``` -npx canicode calibrate-analyze "$ARGUMENTS" --run-dir $RUN_DIR -``` - -Read `$RUN_DIR/analysis.json`. If `issueCount` is 0, stop here. - -Read the `calibrationTier` field from `analysis.json`. The CLI determines the tier based on grade percentage. Branch accordingly: - -- **`"full"`**: Full pipeline — proceed to Step 2 (Converter + visual-compare + Gap Analysis) -- **`"visual-only"`**: Converter + visual-compare, but **skip Step 3 (Gap Analysis)**. Gap analysis on diff images is only meaningful at high similarity. - -**Always run the Converter** regardless of tier. Low-scoring designs need score validation the most. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Analysis","timestamp":"","result":"nodes= issues= grade= (%) tier=","durationMs":} -``` - -If tier is `"visual-only"`, append after Converter completes: -```json -{"step":"Gap Analyzer","timestamp":"","result":"SKIPPED — tier=visual-only, gap analysis skipped","durationMs":0} -``` - -### Step 2 — Converter - -Read the analysis JSON to extract `fileKey`. Parse the root nodeId from the Figma URL. Extract a short fixture name from the URL for cache lookup. - -Spawn a `general-purpose` subagent. In the prompt, include the full converter instructions from `.claude/agents/calibration/converter.md` and add: - -``` -This is a Figma URL. Use `get_design_context` MCP tool with fileKey and root nodeId. -Figma URL: -fileKey: -Root nodeId: -Run directory: -``` - -After the Converter returns, **verify** files exist in $RUN_DIR: -```bash -ls $RUN_DIR/conversion.json $RUN_DIR/output.html -``` - -If `conversion.json` is missing, write it yourself from the Converter's returned summary. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Converter","timestamp":"","result":"similarity=% difficulty=","durationMs":} -``` - -### Step 3 — Gap Analysis - -Check whether screenshots were produced: - -```bash -test -f $RUN_DIR/figma.png && echo "EXISTS" || echo "MISSING" -``` - -**If MISSING**: append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Gap Analyzer","timestamp":"","result":"SKIPPED — figma.png not found","durationMs":0} -``` -Proceed to Step 4. - -**If EXISTS**: spawn the `calibration-gap-analyzer` subagent. In the prompt include: -- Screenshot paths: `$RUN_DIR/figma.png`, `$RUN_DIR/code.png`, `$RUN_DIR/diff.png` -- Similarity score, HTML path, fixture/URL, analysis JSON path -- The Converter's interpretations list -- **Tell the agent: "Return the gap analysis as JSON. Do NOT write any files."** - -After the Gap Analyzer returns, **you** write the JSON to `$RUN_DIR/gaps.json`. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Gap Analyzer","timestamp":"","result":"gaps= actionable=","durationMs":} -``` - -### Step 4 — Evaluation (CLI) - -``` -npx canicode calibrate-evaluate _ _ --run-dir $RUN_DIR -``` - -Read `$RUN_DIR/summary.md`, extract proposals. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Evaluation","timestamp":"","result":"overscored= underscored= validated= proposals=","durationMs":} -``` - -If zero proposals, write `$RUN_DIR/debate.json` with skip reason and jump to Step 7: -```json -{"critic": null, "arbitrator": null, "skipped": "zero proposals from evaluation"} -``` - -### Step 5 — Critic - -Spawn the `calibration-critic` subagent. In the prompt: -- Include only the proposal list (NOT the Converter's reasoning) -- **Tell the agent: "Return your reviews as JSON. Do NOT write any files."** - -After the Critic returns, **you** write the JSON to `$RUN_DIR/debate.json`. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Critic","timestamp":"","result":"approved= rejected= revised=","durationMs":} -``` - -### Step 6 — Arbitrator - -Spawn the `calibration-arbitrator` subagent. In the prompt: -- Include proposals and the Critic's reviews -- **Tell the agent: "Return your decisions as JSON. Only edit rule-config.ts if applying changes. Do NOT write to logs."** - -After the Arbitrator returns, **you** update `$RUN_DIR/debate.json` — add the `arbitrator` field. - -Append to `$RUN_DIR/activity.jsonl`: -```json -{"step":"Arbitrator","timestamp":"","result":"applied= rejected=","durationMs":} -``` - -### Step 7 — Generate Report - -``` -npx canicode calibrate-gap-report --output logs/calibration/REPORT.md -``` - -### Done - -Report the final summary: similarity, proposals, decisions, and path to `logs/calibration/REPORT.md`. - -## Rules - -- Each agent must be a SEPARATE subagent call (isolated context). -- Pass only structured data between agents — never raw reasoning. -- The Critic must NOT see the Runner's or Converter's reasoning, only the proposal list. -- Only the Arbitrator may edit `rule-config.ts`. -- Steps 1, 4, 7 are CLI commands — run them directly with Bash. -- **CRITICAL: YOU write all files to $RUN_DIR. Subagents (Gap Analyzer, Critic, Arbitrator) MUST return JSON as text — tell them "Do NOT write any files." You are the only one who writes to $RUN_DIR.** -- **CRITICAL: After each step, append to $RUN_DIR/activity.jsonl yourself. Do NOT rely on subagents to append.** diff --git a/.claude/commands/calibrate-loop.md b/.claude/commands/calibrate-loop.md index d688dcfe..18386512 100644 --- a/.claude/commands/calibrate-loop.md +++ b/.claude/commands/calibrate-loop.md @@ -1,4 +1,4 @@ -Run a calibration debate loop using local fixture directories. No Figma MCP needed. +Run a calibration debate loop using local fixture directories. Input: $ARGUMENTS (fixture directory path, e.g. `fixtures/material3-kit`) diff --git a/.claude/commands/mcp-vs-cli.md b/.claude/commands/mcp-vs-cli.md deleted file mode 100644 index 230b7ff5..00000000 --- a/.claude/commands/mcp-vs-cli.md +++ /dev/null @@ -1,41 +0,0 @@ -Run a canicode MCP vs CLI comparison test on the given Figma design. - -**Figma URL**: $ARGUMENTS - -## Steps - -### 0. Pre-check -- If no Figma URL is provided in the arguments, ask the user for one. -- Check if `FIGMA_TOKEN` environment variable is set. If not, ask the user to provide their Figma Personal Access Token before proceeding. -- Extract `fileKey` and `nodeId` from the URL (convert "-" to ":" in nodeId). - -### 1. Version check -- Call the canicode MCP `version` tool to confirm the installed version. - -### 2. MCP analysis -- Call Figma MCP `get_metadata` with the fileKey and nodeId. -- Call Figma MCP `get_design_context` with the fileKey and nodeId. -- Call canicode MCP `analyze` with: - - `designData` = get_metadata result - - `designContext` = get_design_context code output - - `fileKey` and `fileName` from the Figma file -- Save the full JSON result for comparison. - -### 3. CLI analysis -- Run: `FIGMA_TOKEN= npx canicode analyze "" --json` -- Save the full JSON result for comparison. - -### 4. Compare and report -- Compare the two JSON results field by field. -- Identify all differences in scores, issue counts, and issuesByRule. -- Investigate the root cause of each discrepancy by examining how the Figma MCP code output (get_design_context) differs from the raw Figma API data. -- Write a markdown report to `reports/mcp-vs-cli-.md` (e.g. `reports/mcp-vs-cli-2026-03-22.md`), including: - - Overview (test subject, canicode version, Figma URL, node info) - - Score comparison table (overall + all categories) - - Issue count comparison table - - Issues by rule comparison table - - Root cause analysis for every discrepancy found - - Data flow diagrams for both MCP and CLI paths - - Key takeaways and recommendations - - Report generation date and canicode version -- If the results are identical, note that explicitly in the report. diff --git a/.claude/skills/canicode/SKILL.md b/.claude/skills/canicode/SKILL.md index 9f9b5482..5620680a 100644 --- a/.claude/skills/canicode/SKILL.md +++ b/.claude/skills/canicode/SKILL.md @@ -7,150 +7,58 @@ description: Analyze Figma designs for development-friendliness and AI-friendlin Analyze Figma design files to score how development-friendly and AI-friendly they are. Produces actionable reports with specific issues and fix suggestions. -## Prerequisites: Official Figma MCP Server +## Prerequisites -This skill requires the **official Figma MCP server** (`https://mcp.figma.com/mcp`) to be connected. +This skill uses the CLI. Requires either: +- A **saved fixture** (from `canicode save-fixture`) +- A **FIGMA_TOKEN** for live Figma URLs -**Before doing anything else**, check if `mcp__figma__get_metadata` and `mcp__figma__get_design_context` are available in this session: -- If available → proceed with analysis -- If NOT available → stop and show the user this setup guide: +## How to Analyze -``` -The official Figma MCP server is required but not connected. - -Set it up at the project level: - - claude mcp add -s project -t http figma https://mcp.figma.com/mcp - -This creates a .mcp.json file in your project root. -After adding, restart the Claude Code session to activate the connection. +### From a Figma URL -Note: The first time you connect, Figma OAuth will prompt you to authorize in the browser. +```bash +npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --token YOUR_TOKEN ``` -Do NOT proceed with analysis if Figma MCP is not available. Do NOT fall back to CLI `--mcp` mode. - -## CLI vs this skill (Figma MCP) - -This skill is the **MCP path** only. For some goals, `canicode analyze` with **FIGMA_TOKEN** (CLI) is a better fit: - -| Feature | CLI (REST API) | This skill (Figma MCP) | -|---------|:-:|:-:| -| Node structure | ✅ Full tree | ✅ XML metadata | -| Style values | ✅ Raw Figma JSON | ✅ React+Tailwind from design context | -| Component metadata (name, desc) | ✅ | ❌ | -| Component master trees | ✅ `componentDefinitions` | ❌ | -| Annotations (dev mode) | ❌ private beta | ✅ `data-annotations` in code | -| Screenshots | ✅ via API | ✅ `get_screenshot` | -| FIGMA_TOKEN | Required for live API | Not required when Figma MCP is connected | - -Use **CLI + token** when the user needs accurate component analysis. Use **this skill** for fast checks and workflows that rely on dev annotations. - -## How to Analyze a Figma URL - -When the user provides a Figma URL, follow these steps: - -### Step 1: Parse the URL -Extract `fileKey` and `nodeId` from the URL: -- `figma.com/design/:fileKey/:fileName?node-id=:nodeId` → convert `-` to `:` in nodeId - -### Step 2: Fetch design data via Figma MCP (two calls) - -**Call 1 — Structure:** Call `mcp__figma__get_metadata` to get the node tree (XML): -``` -fileKey: (extracted from URL) -nodeId: (extracted from URL, use ":" separator e.g. "127:2") +Or if FIGMA_TOKEN is set in environment: +```bash +npx canicode analyze "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" ``` -**Call 2 — Style enrichment:** Call `mcp__figma__get_design_context` to get style data (code): -``` -fileKey: (extracted from URL) -nodeId: (extracted from URL, use ":" separator e.g. "127:2") -excludeScreenshot: true -``` +### From a saved fixture -Both calls can be made in parallel. - -### Step 3: Convert to fixture JSON and analyze - -1. Parse the XML response from `mcp__figma__get_metadata` -2. Convert to AnalysisFile JSON format: -```json -{ - "fileKey": "", - "name": "", - "lastModified": "", - "version": "mcp", - "document": { - // Convert XML nodes to AnalysisNode format: - // - // becomes: - // { "id": "1:2", "name": "MyFrame", "type": "FRAME", "visible": true, - // "absoluteBoundingBox": { "x": 0, "y": 0, "width": 100, "height": 50 } } - // - // XML tag → type mapping: - // frame → FRAME, group → GROUP, section → SECTION, - // component → COMPONENT, component-set → COMPONENT_SET, - // instance → INSTANCE, rectangle → RECTANGLE, - // text → TEXT, vector → VECTOR, ellipse → ELLIPSE, - // line → LINE, boolean-operation → BOOLEAN_OPERATION - // - // hidden="true" → visible: false - }, - "components": {}, - "styles": {} -} +```bash +npx canicode analyze fixtures/my-design ``` -3. **Enrich with design context:** Parse the code from `get_design_context` to extract style properties and merge into the AnalysisFile nodes. Extract from Tailwind classes: - - `flex` / `flex-col` → `layoutMode: "HORIZONTAL" / "VERTICAL"` - - `absolute` → `layoutPositioning: "ABSOLUTE"` - - `gap-N` → `itemSpacing` (px) - - `p-N`, `px-N`, `py-N`, `pl-N`, `pr-N`, `pt-N`, `pb-N` → padding values - - `bg-[#hex]` → `fills` array with SOLID type - - `bg-[var(--token)]` → `fills` with bound variable reference - - `shadow-*` → `effects` array with DROP_SHADOW type - - `w-full` / `h-full` → `layoutSizingHorizontal/Vertical: "FILL"` - - `w-fit` / `h-fit` → `layoutSizingHorizontal/Vertical: "HUG"` - - `w-[Npx]` / `h-[Npx]` → `layoutSizingHorizontal/Vertical: "FIXED"` - - Also check the code comment header (e.g. `/* NodeName — 905x680 COMPONENT, vertical auto-layout */`) for: - - Auto-layout presence and direction - - Node type confirmation - -4. Save to `fixtures/_mcp-temp/data.json` (create directory if needed) -5. Run: `npx canicode analyze fixtures/_mcp-temp [options]` -6. Clean up: delete `fixtures/_mcp-temp/` directory after analysis - -**IMPORTANT:** Do NOT use `npx canicode analyze --mcp`. The `--mcp` CLI flag has been removed. - -## Analyzing a JSON fixture (no MCP needed) +### Save a fixture for offline analysis ```bash -npx canicode analyze fixtures/my-design +npx canicode save-fixture "https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" --output fixtures/my-design ``` ## Analysis Options ### Presets -- `--preset relaxed` -- Downgrades blocking to risk, reduces scores by 50% -- `--preset dev-friendly` -- Focuses on layout and handoff rules only -- `--preset ai-ready` -- Boosts structure and naming rule weights by 150% -- `--preset strict` -- Enables all rules, increases all scores by 150% +- `--preset relaxed` — Downgrades blocking to risk, reduces scores by 50% +- `--preset dev-friendly` — Focuses on layout and handoff rules only +- `--preset ai-ready` — Boosts structure and naming rule weights by 150% +- `--preset strict` — Increases all scores by 150% -### Custom rules +### Config overrides ```bash -npx canicode analyze --custom-rules ./my-rules.json +npx canicode analyze --config ./my-config.json ``` -### Config overrides +### JSON output ```bash -npx canicode analyze --config ./my-config.json +npx canicode analyze --json ``` ## What It Reports -39 rules across 6 categories: Layout, Design Token, Component, Naming, AI Readability, Handoff Risk. +29 rules across 5 categories: Structure, Token, Component, Naming, Behavior. Each issue includes: - Rule ID and severity (blocking / risk / missing-info / suggestion) diff --git a/.mcpb b/.mcpb index da5366fb..99ca4cf4 100644 --- a/.mcpb +++ b/.mcpb @@ -1,7 +1,7 @@ { "name": "canicode", "display_name": "CanICode", - "description": "Analyze Figma design structures for development-friendliness and AI-friendliness. Scores designs across 39 rules in 6 categories (layout, design tokens, components, naming, AI readability, handoff risk) and generates detailed HTML reports with actionable issues.", + "description": "Analyze Figma design structures for development-friendliness and AI-friendliness. Scores designs across 29 rules in 5 categories (structure, token, component, naming, behavior) and generates detailed HTML reports with actionable issues.", "version": "0.5.1", "author": "minseon", "repository": "https://github.com/let-sunny/canicode", @@ -43,36 +43,18 @@ { "name": "analyze", "title": "Analyze Figma Design", - "description": "Analyze a Figma design for development-friendliness and AI-friendliness. Accepts design data from Figma MCP or a Figma URL. Returns a JSON score summary and generates an HTML report.", + "description": "Analyze a Figma design for development-friendliness and AI-friendliness. Provide a Figma URL or fixture path via the input parameter. Returns a JSON score summary and generates an HTML report.", "annotations": { "readOnlyHint": false, "destructiveHint": false, "openWorldHint": true }, "parameters": [ - { - "name": "designData", - "type": "string", - "required": false, - "description": "Figma node data from Figma MCP get_metadata (XML or JSON)" - }, { "name": "input", "type": "string", - "required": false, - "description": "Figma URL (requires FIGMA_TOKEN)" - }, - { - "name": "fileKey", - "type": "string", - "required": false, - "description": "Figma file key (used with designData to generate deep links)" - }, - { - "name": "fileName", - "type": "string", - "required": false, - "description": "Figma file name (used with designData for display)" + "required": true, + "description": "Figma URL or local fixture path. Requires FIGMA_TOKEN for live URLs." }, { "name": "token", @@ -97,12 +79,6 @@ "type": "string", "required": false, "description": "Path to config JSON file for rule overrides" - }, - { - "name": "customRulesPath", - "type": "string", - "required": false, - "description": "Path to custom rules JSON file" } ] }, @@ -120,7 +96,7 @@ { "name": "docs", "title": "Get Customization Guide", - "description": "Get documentation on config overrides, custom rules, all 39 rule IDs with default scores, and example configurations.", + "description": "Get documentation on config overrides, custom rules, all 29 rule IDs with default scores, and example configurations.", "annotations": { "readOnlyHint": true, "destructiveHint": false, @@ -139,7 +115,7 @@ "environment_variables": [ { "name": "FIGMA_TOKEN", - "description": "Figma personal access token. Required for fetching designs via REST API and posting comments to Figma. Optional when using Figma MCP to provide design data directly.", + "description": "Figma personal access token. Required for fetching designs via REST API and posting comments to Figma.", "required": false } ], @@ -155,10 +131,6 @@ "title": "Analyze a Figma design via URL", "prompt": "Analyze this Figma design: https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" }, - { - "title": "Analyze with Figma MCP (two-step)", - "prompt": "Get the metadata for this Figma page and then analyze it with canicode: https://www.figma.com/design/ABC123/MyDesign?node-id=1-234" - }, { "title": "List all analysis rules", "prompt": "Show me all the analysis rules that canicode checks for" diff --git a/CLAUDE.md b/CLAUDE.md index b0902463..6b2360ce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,33 +77,12 @@ app/ # Browser runtime **2. MCP Server (`canicode-mcp`)** - Install: `claude mcp add canicode -- npx -y -p canicode canicode-mcp` - Tools: `analyze`, `list-rules`, `visual-compare`, `version`, `docs` -- Works with Figma MCP: user installs official Figma MCP → Claude Code orchestrates both - - Figma MCP `get_metadata` → XML (structure) + `get_design_context` → code (styles) - - canicode MCP `analyze(designData: XML, designContext: code)` — hybrid enrichment - - No FIGMA_TOKEN needed when using Figma MCP -- Also works standalone with FIGMA_TOKEN (REST API fallback via `input` param) - -**CLI vs MCP Feature Comparison** - -| Feature | CLI (REST API) | MCP (Figma MCP) | -|---------|:-:|:-:| -| Node structure | ✅ Full tree | ✅ XML metadata | -| Style values | ✅ Raw Figma JSON | ✅ React+Tailwind code | -| Component metadata (name, desc) | ✅ | ❌ | -| Component master trees | ✅ `componentDefinitions` | ❌ | -| Annotations (dev mode) | ❌ private beta | ✅ `data-annotations` | -| Screenshots | ✅ via API | ✅ `get_screenshot` | -| FIGMA_TOKEN required | ✅ | ❌ | - -**When to use which:** -- Accurate component analysis (style overrides, missing-component) → **CLI with FIGMA_TOKEN** -- Quick structure/style check, annotation-aware workflows → **MCP** -- Offline/CI analysis → **CLI with saved fixtures** +- Data source: Figma REST API via `input` param (Figma URL or fixture path). Requires FIGMA_TOKEN for live URLs. + **3. Claude Code Skill (`/canicode`)** - Location: `.claude/skills/canicode/SKILL.md` (copy to any project) -- Requires: Official Figma MCP (`https://mcp.figma.com/mcp`) at project level -- Flow: Figma MCP `get_metadata` (structure) + `get_design_context` (styles) → enriched fixture JSON → `canicode analyze` +- Uses CLI (`canicode analyze`) with FIGMA_TOKEN - Lightweight alternative to MCP server — no canicode MCP installation needed **4. Web App (GitHub Pages)** @@ -129,14 +108,9 @@ Calibration commands are NOT exposed as CLI commands. They run exclusively insid - Cross-run evidence: Evaluation appends overscored/underscored findings to `data/calibration-evidence.json`; Gap Analyzer appends uncovered gaps to `data/discovery-evidence.json` (environment/tooling noise is auto-filtered) - After Arbitrator applies changes, evidence for applied rules is pruned (`calibrate-prune-evidence`) - Each run creates a self-contained directory: `logs/calibration/--/` -- No Figma MCP or API keys needed — works fully offline +- No API keys needed — works fully offline - Auto-commits agreed score changes -**`/calibrate-loop-deep` (Claude Code command)** -- Role: Deep calibration using Figma MCP for precise design context -- Input: Figma URL (e.g. `https://www.figma.com/design/ABC123/MyDesign?node-id=1-234`) -- Flow: Same as `/calibrate-loop` but Converter uses Figma MCP `get_design_context` for richer style data - **`/calibrate-night` (Claude Code command)** - Role: Run calibration on multiple fixtures sequentially, then generate aggregate report - Input: fixture directory path (e.g. `fixtures/my-designs`) — auto-discovers active fixtures diff --git a/README.md b/README.md index ef17c27c..56fa7c88 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Setup: `canicode init --token figd_xxxxxxxxxxxxx` | View, Collab | 6 req/month | 6 req/month | | Dev, Full | 6 req/month | 10–20 req/min | -Hitting 429 errors? Make sure the file is in a paid workspace. Or use MCP (no token, separate rate limit pool). Or `save-fixture` once and analyze locally. [Full details](https://developers.figma.com/docs/rest-api/rate-limits/) +Hitting 429 errors? Make sure the file is in a paid workspace. Or `save-fixture` once and analyze locally. [Full details](https://developers.figma.com/docs/rest-api/rate-limits/) @@ -128,44 +128,15 @@ Then ask: *"Analyze this Figma design: https://www.figma.com/design/..."* canicode's rule engine analyzes the design data — the AI assistant just orchestrates the calls. -Or with a Figma API token (no Figma MCP needed): +With a Figma API token: ```bash claude mcp add canicode -e FIGMA_TOKEN=figd_xxxxxxxxxxxxx -- npx -y -p canicode canicode-mcp ``` For Cursor / Claude Desktop config, see [`docs/REFERENCE.md`](docs/REFERENCE.md). -**Figma MCP Rate Limits** - -| Plan | Limit | -|------|-------| -| Starter | 6 tool calls/month | -| Pro / Org — Full or Dev seat | 200 tool calls/day | -| Enterprise — Full or Dev seat | 600 tool calls/day | - -MCP and CLI use separate rate limit pools — switching to MCP won't affect your CLI quota. [Full details](https://developers.figma.com/docs/figma-mcp-server/plans-access-and-permissions/) - -
-CLI vs MCP (feature comparison) - -| Feature | CLI (REST API) | MCP (Figma MCP) | -|---------|:-:|:-:| -| Node structure | Full tree | XML metadata | -| Style values | Raw Figma JSON | React+Tailwind code | -| Component metadata (name, desc) | Yes | No | -| Component master trees | Yes | No | -| Annotations (dev mode) | No (private beta) | Yes | -| Screenshots | Yes | Yes | -| FIGMA_TOKEN required | Yes | No | - -**When to use which:** -- Accurate component analysis → **CLI with FIGMA_TOKEN** -- Quick checks or annotation-aware flows → **MCP** -- Offline/CI → **CLI with saved fixtures** (`save-fixture`) - -
Design to Code (prepare implementation package) @@ -199,7 +170,7 @@ Feed `design-tree.txt` + `PROMPT.md` to your AI assistant (Claude, Cursor, etc.) cp -r .claude/skills/canicode /your-project/.claude/skills/ ``` -Requires the official Figma MCP. Then use `/canicode` with a Figma URL. +Requires FIGMA_TOKEN. Then use `/canicode` with a Figma URL.
diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md index 0b4de493..d681e93e 100644 --- a/docs/CALIBRATION.md +++ b/docs/CALIBRATION.md @@ -143,11 +143,6 @@ Calibration runs inside Claude Code using the `/calibrate-loop` command: /calibrate-loop fixtures/my-design ``` -For live Figma data with MCP: -```bash -/calibrate-loop-deep https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 -``` - Calibration is an internal development tool — it is not exposed to end users. --- diff --git a/docs/MCP-VS-CLI.md b/docs/MCP-VS-CLI.md deleted file mode 100644 index 74d6c3ae..00000000 --- a/docs/MCP-VS-CLI.md +++ /dev/null @@ -1,123 +0,0 @@ -# canicode Analysis: MCP vs CLI Comparison Report - -## Overview - -This report documents a side-by-side comparison of analyzing the same Figma design using two different canicode (v0.8.2) analysis paths: - -1. **MCP path** — Using the official Figma MCP server (`get_metadata` + `get_design_context`) to retrieve design data, then passing it to canicode's `analyze` tool via MCP. -2. **CLI path** — Using `npx canicode analyze --json` which fetches data directly from the Figma REST API. - -### Test Subject - -- **File**: `Untitled` -- **Node**: `127:2` (Frame 2) -- **URL**: `https://www.figma.com/design/KVCLAjaqxDqJw8e7H5YgIe/Untitled?node-id=127-2` -- **Structure**: A parent frame (`Frame 2`, red background `#b21e1e`) containing a child frame (`Frame 3`, white background `#ffffff`) with 10px padding. - ---- - -## Results Comparison - -### Scores - -| Category | MCP | CLI | Match | -|----------|-----|-----|-------| -| **Overall** | C (67%) | C (67%) | Yes | -| Structure | 100% | 100% | Yes | -| Token | 21% | 21% | Yes | -| Component | 100% | 100% | Yes | -| Naming | 24% | 24% | Yes | -| Behavior | 100% | 100% | Yes | - -### Issue Counts - -| Category | MCP | CLI | Match | -|----------|-----|-----|-------| -| **Total Issues** | **5** | **6** | **No** | -| Token issues | 2 | 3 | No | -| Naming issues | 2 | 2 | Yes | -| Structure issues | 1 | 1 | Yes | - -### Issues by Rule - -| Rule | MCP | CLI | Match | -|------|-----|-----|-------| -| `raw-color` | **1** | **2** | **No** | -| `inconsistent-spacing` | 1 | 1 | Yes | -| `default-name` | 2 | 2 | Yes | -| `unnecessary-node` | 1 | 1 | Yes | - ---- - -## Root Cause of Discrepancy - -The difference comes down to **one missed `raw-color` issue** in the MCP path. - -### What happened - -The Figma MCP's `get_design_context` tool generates React + Tailwind reference code as its output. During this code generation, Figma's server performs its own color mapping: - -| Node | Original Figma Fill | MCP Code Output | CLI Raw Data | -|------|---------------------|-----------------|--------------| -| Frame 2 (127:2) | `#b21e1e` | `bg-[#b21e1e]` | `#b21e1e` | -| Frame 3 (127:3) | `#ffffff` | `bg-white` | `#ffffff` | - -The critical transformation: **`#ffffff` was converted to the Tailwind utility class `bg-white`** by Figma's code generation layer. - -### Why this matters - -When canicode analyzes the MCP-provided code: -- `bg-[#b21e1e]` — contains a raw hex value → flagged as `raw-color` -- `bg-white` — is a named Tailwind color, not a raw hex → **not flagged** - -When canicode analyzes via CLI (direct Figma REST API): -- `#b21e1e` — raw hex → flagged as `raw-color` -- `#ffffff` — raw hex → **flagged as `raw-color`** - -The Figma MCP's code generation layer effectively "sanitized" the white color before canicode could evaluate it, causing a false negative. - ---- - -## Data Flow Comparison - -### MCP Path - -``` -Figma Design - → Figma MCP get_metadata (XML structure) - → Figma MCP get_design_context (React + Tailwind code) - ⚠️ Color mapping happens here (#ffffff → bg-white) - → canicode analyze (designData + designContext) - → Result: 5 issues -``` - -### CLI Path - -``` -Figma Design - → Figma REST API (raw node data with original fill values) - → canicode analyze (direct from API response) - → Result: 6 issues -``` - ---- - -## Key Takeaway - -| | MCP Path | CLI Path | -|-|----------|----------| -| **Data source** | Figma MCP (code generation layer) | Figma REST API (raw data) | -| **Accuracy** | May miss issues due to intermediate transformations | More accurate — analyzes original design values | -| **Token required** | No (uses MCP server auth) | Yes (`FIGMA_TOKEN` required) | -| **Convenience** | Integrated into AI coding workflows | Standalone, CI/CD friendly | -| **Best for** | Quick checks during development | Authoritative audits and reports | - -### Recommendation - -- Use **CLI** when you need the most accurate and complete analysis — especially for audits, CI pipelines, or generating reports. -- Use **MCP** for quick feedback during development workflows where convenience matters and minor discrepancies are acceptable. -- Be aware that **any intermediate code generation layer** (Figma MCP, design-to-code tools) may transform raw design values before canicode sees them, potentially masking token-related issues. - ---- - -*Report generated on 2026-03-22 using canicode v0.8.2* diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index f4393086..932e300f 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -7,17 +7,12 @@ import { const InitOptionsSchema = z.object({ token: z.string().optional(), - mcp: z.boolean().optional(), -}).refine( - (opts) => !(opts.token && opts.mcp), - { message: "--token and --mcp are mutually exclusive. Choose one." } -); +}); export function registerInit(cli: CAC): void { cli - .command("init", "Set up canicode (Figma token or MCP)") + .command("init", "Set up canicode with Figma API token") .option("--token ", "Save Figma API token to ~/.canicode/") - .option("--mcp", "Show Figma MCP setup instructions") .action((rawOptions: Record) => { try { const parseResult = InitOptionsSchema.safeParse(rawOptions); @@ -37,26 +32,10 @@ export function registerInit(cli: CAC): void { return; } - if (options.mcp) { - console.log(`FIGMA MCP SETUP (for Claude Code)\n`); - console.log(`1. Register the official Figma MCP server at project level:`); - console.log(` claude mcp add -s project -t http figma https://mcp.figma.com/mcp\n`); - console.log(` This creates .mcp.json in your project root.\n`); - console.log(`2. Use the /canicode skill in Claude Code:`); - console.log(` /canicode https://www.figma.com/design/.../MyDesign?node-id=1-234\n`); - console.log(` The skill calls Figma MCP directly — no FIGMA_TOKEN needed.`); - return; - } - // No flags: show setup guide console.log(`CANICODE SETUP\n`); - console.log(`Choose your Figma data source:\n`); - console.log(`Option 1: REST API (recommended for CI/automation)`); console.log(` canicode init --token YOUR_FIGMA_TOKEN`); console.log(` Get token: figma.com > Settings > Personal access tokens\n`); - console.log(`Option 2: Figma MCP (recommended for Claude Code)`); - console.log(` canicode init --mcp`); - console.log(` Uses the /canicode skill in Claude Code with official Figma MCP\n`); console.log(`After setup:`); console.log(` canicode analyze "https://www.figma.com/design/..."`); } catch (error) { diff --git a/src/cli/docs.ts b/src/cli/docs.ts index 807a3377..2d4c8864 100644 --- a/src/cli/docs.ts +++ b/src/cli/docs.ts @@ -50,26 +50,17 @@ CANICODE SETUP GUIDE ~/.canicode/reports/report-YYYY-MM-DD-HH-mm-.html ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 2. CLAUDE CODE SKILL (Figma MCP, no token needed) + 2. CLAUDE CODE SKILL (requires FIGMA_TOKEN) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - Requires the official Figma MCP server at project level. - - Setup (once): - claude mcp add -s project -t http figma https://mcp.figma.com/mcp + Setup: + cp -r .claude/skills/canicode /your-project/.claude/skills/ Use (in Claude Code): /canicode https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 - Flow: - Claude Code - -> Figma MCP get_metadata(fileKey, nodeId) -> XML node tree (structure) - -> Figma MCP get_design_context(fileKey, nodeId) -> code (styles) - -> Merge into fixture JSON (structure + styles) - -> canicode analyze fixture.json -> report - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - TOKEN PRIORITY (CLI mode) + TOKEN PRIORITY ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1. --token flag (one-time override) @@ -81,26 +72,11 @@ CANICODE SETUP GUIDE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ CI/CD, automation -> CLI + FIGMA_TOKEN env var - Claude Code (full) -> canicode MCP + Figma MCP (no token needed) - Claude Code (light) -> /canicode skill + Figma MCP (no token needed) + Claude Code (full) -> canicode MCP server + FIGMA_TOKEN + Claude Code (light) -> /canicode skill + FIGMA_TOKEN In Figma -> Figma Plugin Browser -> Web App (GitHub Pages) Quick trial, offline -> CLI + JSON fixtures - -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - DATA SOURCE DIFFERENCES -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - CLI (REST API) Reads raw Figma node data directly. - Most accurate — all style properties available. - - MCP / Skill Uses Figma MCP's get_metadata (structure) and - get_design_context (styles). Style data is extracted - from Figma MCP's own generated code (React + Tailwind), - not raw Figma node properties. Results may differ - slightly from CLI due to this interpretation layer. - - Details: github.com/let-sunny/canicode/blob/main/docs/MCP-VS-CLI.md `.trimStart()); } diff --git a/src/cli/index.ts b/src/cli/index.ts index a020952c..9ee3caef 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -94,10 +94,7 @@ cli.help((sections) => { sections.push( { title: "\nSetup", - body: [ - ` canicode init --token Save Figma token to ~/.canicode/`, - ` canicode init --mcp Show MCP setup instructions`, - ].join("\n"), + body: ` canicode init --token Save Figma token to ~/.canicode/`, }, { title: "\nData source", diff --git a/src/core/adapters/figma-mcp-adapter.ts b/src/core/adapters/figma-mcp-adapter.ts deleted file mode 100644 index 61c39667..00000000 --- a/src/core/adapters/figma-mcp-adapter.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { AnalysisFile, AnalysisNode, AnalysisNodeType } from "../contracts/figma-node.js"; -import { parseDesignContextCode, parseCodeHeader, enrichNodeWithStyles } from "./tailwind-parser.js"; - -/** - * Map MCP XML tag names to Figma AnalysisNodeType - */ -const TAG_TYPE_MAP: Record = { - canvas: "CANVAS", - frame: "FRAME", - group: "GROUP", - section: "SECTION", - component: "COMPONENT", - "component-set": "COMPONENT_SET", - instance: "INSTANCE", - rectangle: "RECTANGLE", - "rounded-rectangle": "RECTANGLE", - ellipse: "ELLIPSE", - vector: "VECTOR", - text: "TEXT", - line: "LINE", - "boolean-operation": "BOOLEAN_OPERATION", - star: "STAR", - "regular-polygon": "REGULAR_POLYGON", - slice: "SLICE", - sticky: "STICKY", - table: "TABLE", - "table-cell": "TABLE_CELL", - symbol: "COMPONENT", - slot: "FRAME", -}; - -interface ParsedXmlNode { - tag: string; - attrs: Record; - children: ParsedXmlNode[]; -} - -/** - * Minimal XML parser for MCP metadata output. - * Handles self-closing tags and nested elements. - */ -function parseXml(xml: string): ParsedXmlNode[] { - const nodes: ParsedXmlNode[] = []; - const stack: ParsedXmlNode[] = []; - // Match opening tags (with attrs), closing tags, and self-closing tags - const tagRegex = /<(\/?)([\w-]+)([^>]*?)(\/?)>/g; - let match: RegExpExecArray | null; - - while ((match = tagRegex.exec(xml)) !== null) { - const isClosing = match[1] === "/"; - const tagName = match[2]!; - const attrString = match[3] ?? ""; - const isSelfClosing = match[4] === "/"; - - if (isClosing) { - // Pop from stack - const finished = stack.pop(); - if (finished) { - const parent = stack[stack.length - 1]; - if (parent) { - parent.children.push(finished); - } else { - nodes.push(finished); - } - } - } else { - const attrs = parseAttributes(attrString); - const node: ParsedXmlNode = { tag: tagName, attrs, children: [] }; - - if (isSelfClosing) { - const parent = stack[stack.length - 1]; - if (parent) { - parent.children.push(node); - } else { - nodes.push(node); - } - } else { - stack.push(node); - } - } - } - - // Flush remaining stack - while (stack.length > 0) { - const finished = stack.pop()!; - const parent = stack[stack.length - 1]; - if (parent) { - parent.children.push(finished); - } else { - nodes.push(finished); - } - } - - return nodes; -} - -function parseAttributes(attrString: string): Record { - const attrs: Record = {}; - const attrRegex = /([\w-]+)="([^"]*)"/g; - let match: RegExpExecArray | null; - while ((match = attrRegex.exec(attrString)) !== null) { - const key = match[1]; - const value = match[2]; - if (key && value !== undefined) { - attrs[key] = value; - } - } - return attrs; -} - -/** - * Convert a parsed XML node to an AnalysisNode - */ -function toAnalysisNode(xmlNode: ParsedXmlNode): AnalysisNode { - const type = TAG_TYPE_MAP[xmlNode.tag] ?? "FRAME"; - const id = xmlNode.attrs["id"] ?? "0:0"; - const name = xmlNode.attrs["name"] ?? xmlNode.tag; - const hidden = xmlNode.attrs["hidden"] === "true"; - - const x = parseFloat(xmlNode.attrs["x"] ?? "0"); - const y = parseFloat(xmlNode.attrs["y"] ?? "0"); - const width = parseFloat(xmlNode.attrs["width"] ?? "0"); - const height = parseFloat(xmlNode.attrs["height"] ?? "0"); - - const node: AnalysisNode = { - id, - name, - type, - visible: !hidden, - absoluteBoundingBox: { x, y, width, height }, - }; - - if (xmlNode.children.length > 0) { - node.children = xmlNode.children.map(toAnalysisNode); - } - - return node; -} - -/** - * Parse MCP get_metadata XML output into an AnalysisFile. - * - * The XML represents a subtree of the Figma file. We wrap it in a - * DOCUMENT node and fill in minimal file metadata. - */ -export function parseMcpMetadataXml( - xml: string, - fileKey: string, - fileName?: string -): AnalysisFile { - const parsed = parseXml(xml); - - // The root XML elements become children of the document - const children = parsed.map(toAnalysisNode); - - // If there's exactly one root element, use it directly as the document root - // Otherwise wrap in a DOCUMENT node - let document: AnalysisNode; - if (children.length === 1 && children[0]) { - document = children[0]; - } else { - document = { - id: "0:0", - name: "Document", - type: "DOCUMENT", - visible: true, - children, - }; - } - - return { - fileKey, - name: fileName ?? fileKey, - lastModified: new Date().toISOString(), - version: "mcp", - document, - components: {}, - styles: {}, - }; -} - -/** - * Enrich an AnalysisFile (from get_metadata) with style data extracted - * from get_design_context code output. - * - * The design context code is React+Tailwind generated for a specific node. - * We parse Tailwind classes to extract layout, color, spacing, and effect - * properties, then merge them into matching AnalysisNodes in the tree. - * - * @param file - AnalysisFile from parseMcpMetadataXml - * @param designContextCode - Code string from get_design_context - * @param targetNodeId - The node ID that get_design_context was called for (optional) - */ -export function enrichWithDesignContext( - file: AnalysisFile, - designContextCode: string, - targetNodeId?: string, -): void { - const header = parseCodeHeader(designContextCode); - const styles = parseDesignContextCode(designContextCode); - - // If header provides auto-layout info, use it - if (header.hasAutoLayout === true && header.layoutDirection) { - if (!styles.layoutMode) styles.layoutMode = header.layoutDirection; - } else if (header.hasAutoLayout === false) { - // Explicitly no auto-layout — don't set layoutMode - delete styles.layoutMode; - } - - // Find the target node and enrich it - if (targetNodeId) { - const node = findNodeById(file.document, targetNodeId); - if (node) { - enrichNodeWithStyles(node, styles); - enrichChildrenFromCode(node, designContextCode); - return; - } - } - - // Fallback: enrich the root document node - enrichNodeWithStyles(file.document, styles); - enrichChildrenFromCode(file.document, designContextCode); -} - -/** - * Try to enrich child nodes by scanning the full code for className patterns. - * For each child, extract classes from JSX elements that match the child's name. - */ -function enrichChildrenFromCode(parent: AnalysisNode, code: string): void { - if (!parent.children) return; - - for (const child of parent.children) { - // Look for comments like "{/* ChildName */}" or "{/* ChildName — ... */}" - const escapedName = child.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const commentPattern = new RegExp( - `\\{/\\*\\s*${escapedName}(?:\\s*—[^*]*)?\\s*\\*/\\}\\s*\\n\\s*<\\w+[^>]*className="([^"]*)"`, - ); - const match = code.match(commentPattern); - if (match?.[1]) { - const childStyles = parseDesignContextCode( - `
`, - ); - enrichNodeWithStyles(child, childStyles); - } - - // Recurse into children - if (child.children) { - enrichChildrenFromCode(child, code); - } - } -} - -function findNodeById(node: AnalysisNode, id: string): AnalysisNode | undefined { - if (node.id === id) return node; - if (node.children) { - for (const child of node.children) { - const found = findNodeById(child, id); - if (found) return found; - } - } - return undefined; -} diff --git a/src/core/adapters/index.ts b/src/core/adapters/index.ts index 2d5e2aa0..282f978e 100644 --- a/src/core/adapters/index.ts +++ b/src/core/adapters/index.ts @@ -4,5 +4,4 @@ export * from "./figma-url-parser.js"; export * from "./figma-client.js"; export * from "./figma-transformer.js"; export * from "./figma-file-loader.js"; -export * from "./figma-mcp-adapter.js"; export * from "./component-resolver.js"; diff --git a/src/core/adapters/tailwind-parser.test.ts b/src/core/adapters/tailwind-parser.test.ts deleted file mode 100644 index 9e7d2847..00000000 --- a/src/core/adapters/tailwind-parser.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - extractStylesFromClasses, - parseDesignContextCode, - parseCodeHeader, - enrichNodeWithStyles, -} from "./tailwind-parser.js"; -import type { AnalysisNode } from "../contracts/figma-node.js"; - -describe("extractStylesFromClasses", () => { - it("extracts flex direction", () => { - expect(extractStylesFromClasses("flex flex-col").layoutMode).toBe("VERTICAL"); - expect(extractStylesFromClasses("flex flex-row").layoutMode).toBe("HORIZONTAL"); - expect(extractStylesFromClasses("flex").layoutMode).toBe("HORIZONTAL"); - }); - - it("extracts positioning", () => { - expect(extractStylesFromClasses("absolute").layoutPositioning).toBe("ABSOLUTE"); - expect(extractStylesFromClasses("relative").layoutPositioning).toBe("AUTO"); - }); - - it("extracts sizing", () => { - const styles = extractStylesFromClasses("w-full h-fit"); - expect(styles.layoutSizingHorizontal).toBe("FILL"); - expect(styles.layoutSizingVertical).toBe("HUG"); - }); - - it("extracts fixed sizing from arbitrary values", () => { - const styles = extractStylesFromClasses("w-[905px] h-[680px]"); - expect(styles.layoutSizingHorizontal).toBe("FIXED"); - expect(styles.layoutSizingVertical).toBe("FIXED"); - }); - - it("extracts gap as itemSpacing", () => { - expect(extractStylesFromClasses("gap-4").itemSpacing).toBe(16); - expect(extractStylesFromClasses("gap-2").itemSpacing).toBe(8); - expect(extractStylesFromClasses("gap-0").itemSpacing).toBe(0); - expect(extractStylesFromClasses("gap-[12px]").itemSpacing).toBe(12); - }); - - it("extracts uniform padding", () => { - const styles = extractStylesFromClasses("p-4"); - expect(styles.paddingLeft).toBe(16); - expect(styles.paddingRight).toBe(16); - expect(styles.paddingTop).toBe(16); - expect(styles.paddingBottom).toBe(16); - }); - - it("extracts axis padding", () => { - const styles = extractStylesFromClasses("px-3 py-2"); - expect(styles.paddingLeft).toBe(12); - expect(styles.paddingRight).toBe(12); - expect(styles.paddingTop).toBe(8); - expect(styles.paddingBottom).toBe(8); - }); - - it("extracts individual padding", () => { - const styles = extractStylesFromClasses("pl-4 pr-2 pt-3 pb-1"); - expect(styles.paddingLeft).toBe(16); - expect(styles.paddingRight).toBe(8); - expect(styles.paddingTop).toBe(12); - expect(styles.paddingBottom).toBe(4); - }); - - it("extracts raw hex color fills", () => { - const styles = extractStylesFromClasses("bg-[#FF0000]"); - expect(styles.fills).toHaveLength(1); - const fill = styles.fills![0] as Record; - expect(fill["type"]).toBe("SOLID"); - const color = fill["color"] as Record; - expect(color["r"]).toBe(1); - expect(color["g"]).toBe(0); - expect(color["b"]).toBe(0); - }); - - it("extracts design token color fills", () => { - const styles = extractStylesFromClasses("bg-[var(--md-sys-color-surface)]"); - expect(styles.fills).toHaveLength(1); - const fill = styles.fills![0] as Record; - expect(fill["boundVariable"]).toBe("var(--md-sys-color-surface)"); - }); - - it("extracts shadow effects", () => { - const styles = extractStylesFromClasses("shadow-lg"); - expect(styles.effects).toHaveLength(1); - const effect = styles.effects![0] as Record; - expect(effect["type"]).toBe("DROP_SHADOW"); - }); -}); - -describe("parseCodeHeader", () => { - it("parses component with no auto-layout", () => { - const code = `/* Examples/Detailed view-Web — 905x680 COMPONENT, no auto-layout */`; - const header = parseCodeHeader(code); - expect(header.name).toBe("Examples/Detailed view-Web"); - expect(header.width).toBe(905); - expect(header.height).toBe(680); - expect(header.type).toBe("COMPONENT"); - expect(header.hasAutoLayout).toBe(false); - }); - - it("parses frame with vertical auto-layout", () => { - const code = `/* MyFrame — 412x461 FRAME, vertical auto-layout */`; - const header = parseCodeHeader(code); - expect(header.name).toBe("MyFrame"); - expect(header.hasAutoLayout).toBe(true); - expect(header.layoutDirection).toBe("VERTICAL"); - }); -}); - -describe("parseDesignContextCode", () => { - it("extracts root styles from JSX", () => { - const code = `export function MyComp() { - return ( -
- Hello -
- ); -}`; - const styles = parseDesignContextCode(code); - expect(styles.layoutMode).toBe("VERTICAL"); - expect(styles.itemSpacing).toBe(16); - expect(styles.paddingLeft).toBe(24); - expect(styles.fills).toBeDefined(); - expect(styles.fills!.length).toBeGreaterThanOrEqual(1); - }); -}); - -describe("enrichNodeWithStyles", () => { - it("only sets missing properties", () => { - const node: AnalysisNode = { - id: "1:1", - name: "Test", - type: "FRAME", - visible: true, - layoutMode: "HORIZONTAL", - }; - enrichNodeWithStyles(node, { - layoutMode: "VERTICAL", - itemSpacing: 16, - }); - expect(node.layoutMode).toBe("HORIZONTAL"); // not overwritten - expect(node.itemSpacing).toBe(16); // newly set - }); -}); - -describe("extractStylesFromClasses — responsive fields", () => { - it("extracts min-w and max-w", () => { - const styles = extractStylesFromClasses("min-w-[120px] max-w-[800px]"); - expect(styles.minWidth).toBe(120); - expect(styles.maxWidth).toBe(800); - }); - - it("extracts min-w and max-w from scale values", () => { - const styles = extractStylesFromClasses("min-w-16 max-w-96"); - expect(styles.minWidth).toBe(64); - expect(styles.maxWidth).toBe(384); - }); - - it("extracts min-h and max-h", () => { - const styles = extractStylesFromClasses("min-h-[50px] max-h-[600px]"); - expect(styles.minHeight).toBe(50); - expect(styles.maxHeight).toBe(600); - }); - - it("extracts flex-wrap", () => { - expect(extractStylesFromClasses("flex-wrap").layoutWrap).toBe("WRAP"); - expect(extractStylesFromClasses("flex-nowrap").layoutWrap).toBe("NO_WRAP"); - }); - - it("extracts gap-y as counterAxisSpacing in flex-row", () => { - expect(extractStylesFromClasses("gap-y-4").counterAxisSpacing).toBe(16); - }); - - it("extracts gap-y as itemSpacing in flex-col", () => { - const styles = extractStylesFromClasses("flex-col gap-y-4"); - expect(styles.itemSpacing).toBe(16); - expect(styles.counterAxisSpacing).toBeUndefined(); - }); - - it("extracts gap-x as counterAxisSpacing in flex-col", () => { - const styles = extractStylesFromClasses("flex-col gap-x-4"); - expect(styles.counterAxisSpacing).toBe(16); - expect(styles.itemSpacing).toBeUndefined(); - }); - - it("resolves gap-x/gap-y correctly regardless of token order", () => { - const styles = extractStylesFromClasses("gap-y-4 flex-col"); - expect(styles.itemSpacing).toBe(16); - expect(styles.counterAxisSpacing).toBeUndefined(); - }); - - it("extracts overflow-hidden as clipsContent", () => { - expect(extractStylesFromClasses("overflow-hidden").clipsContent).toBe(true); - }); - - it("extracts overflow scroll directions", () => { - expect(extractStylesFromClasses("overflow-x-auto").overflowDirection).toBe("HORIZONTAL_SCROLLING"); - expect(extractStylesFromClasses("overflow-y-scroll").overflowDirection).toBe("VERTICAL_SCROLLING"); - expect(extractStylesFromClasses("overflow-auto").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); - }); - - it("combines overflow-x and overflow-y into HORIZONTAL_AND_VERTICAL_SCROLLING", () => { - expect(extractStylesFromClasses("overflow-x-auto overflow-y-auto").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); - expect(extractStylesFromClasses("overflow-y-scroll overflow-x-scroll").overflowDirection).toBe("HORIZONTAL_AND_VERTICAL_SCROLLING"); - }); - - it("applies generic gap to both axes", () => { - const styles = extractStylesFromClasses("flex-row gap-4"); - expect(styles.itemSpacing).toBe(16); - expect(styles.counterAxisSpacing).toBe(16); - }); - - it("allows directional gap to override generic gap", () => { - const styles = extractStylesFromClasses("flex-row gap-4 gap-x-2"); - expect(styles.itemSpacing).toBe(8); - expect(styles.counterAxisSpacing).toBe(16); - }); - - it("handles overflow-x-hidden with overflow-y-auto", () => { - const styles = extractStylesFromClasses("overflow-x-hidden overflow-y-auto"); - expect(styles.clipsContent).toBe(true); - expect(styles.overflowDirection).toBe("VERTICAL_SCROLLING"); - }); - - it("handles overflow-y-hidden suppressing y-scroll", () => { - const styles = extractStylesFromClasses("overflow-auto overflow-y-hidden"); - expect(styles.clipsContent).toBe(true); - expect(styles.overflowDirection).toBe("HORIZONTAL_SCROLLING"); - }); -}); diff --git a/src/core/adapters/tailwind-parser.ts b/src/core/adapters/tailwind-parser.ts deleted file mode 100644 index 1046ae31..00000000 --- a/src/core/adapters/tailwind-parser.ts +++ /dev/null @@ -1,398 +0,0 @@ -import type { AnalysisNode } from "../contracts/figma-node.js"; - -/** - * Style properties extracted from Tailwind/JSX code generated by Figma MCP get_design_context. - * These are merged into AnalysisNode to enrich metadata-only nodes with style information. - */ -export interface ExtractedStyles { - layoutMode?: "HORIZONTAL" | "VERTICAL"; - layoutPositioning?: "AUTO" | "ABSOLUTE"; - layoutSizingHorizontal?: "FIXED" | "HUG" | "FILL"; - layoutSizingVertical?: "FIXED" | "HUG" | "FILL"; - itemSpacing?: number; - paddingLeft?: number; - paddingRight?: number; - paddingTop?: number; - paddingBottom?: number; - fills?: unknown[]; - effects?: unknown[]; - - // Responsive fields - minWidth?: number; - maxWidth?: number; - minHeight?: number; - maxHeight?: number; - layoutWrap?: "WRAP" | "NO_WRAP"; - counterAxisSpacing?: number; - clipsContent?: boolean; - overflowDirection?: "HORIZONTAL_SCROLLING" | "VERTICAL_SCROLLING" | "HORIZONTAL_AND_VERTICAL_SCROLLING"; -} - -/** - * A code block from get_design_context, associated with a node ID. - */ -export interface DesignContextBlock { - nodeId: string; - code: string; -} - -// Tailwind spacing scale: class suffix → px value -const SPACING_SCALE: Record = { - "0": 0, "px": 1, "0.5": 2, "1": 4, "1.5": 6, "2": 8, "2.5": 10, - "3": 12, "3.5": 14, "4": 16, "5": 20, "6": 24, "7": 28, "8": 32, - "9": 36, "10": 40, "11": 44, "12": 48, "14": 56, "16": 64, - "20": 80, "24": 96, "28": 112, "32": 128, "36": 144, "40": 160, - "44": 176, "48": 192, "52": 208, "56": 224, "60": 240, "64": 256, - "72": 288, "80": 320, "96": 384, -}; - -function resolveSpacing(value: string): number | undefined { - // Arbitrary value: gap-[12px] - const arbMatch = value.match(/^\[(\d+(?:\.\d+)?)px\]$/); - if (arbMatch?.[1]) return parseFloat(arbMatch[1]); - return SPACING_SCALE[value]; -} - -/** - * Extract style properties from a className string (Tailwind classes). - */ -export function extractStylesFromClasses(classes: string): ExtractedStyles { - const styles: ExtractedStyles = {}; - const tokens = classes.split(/\s+/); - let gapX: number | undefined; - let gapY: number | undefined; - let gap: number | undefined; - let overflowX = false; - let overflowY = false; - let overflowXHidden = false; - let overflowYHidden = false; - - for (const token of tokens) { - // Layout direction - if (token === "flex" || token === "flex-row") { - styles.layoutMode = "HORIZONTAL"; - } else if (token === "flex-col") { - styles.layoutMode = "VERTICAL"; - } - - // Positioning - else if (token === "absolute") { - styles.layoutPositioning = "ABSOLUTE"; - } else if (token === "relative" || token === "static") { - styles.layoutPositioning = "AUTO"; - } - - // Sizing - else if (token === "w-full") { - styles.layoutSizingHorizontal = "FILL"; - } else if (token === "h-full") { - styles.layoutSizingVertical = "FILL"; - } else if (token === "w-fit") { - styles.layoutSizingHorizontal = "HUG"; - } else if (token === "h-fit") { - styles.layoutSizingVertical = "HUG"; - } else if (token.match(/^w-\[.+\]$/) || token.match(/^w-\d/)) { - styles.layoutSizingHorizontal = "FIXED"; - } else if (token.match(/^h-\[.+\]$/) || token.match(/^h-\d/)) { - styles.layoutSizingVertical = "FIXED"; - } - - // Gap → deferred until layoutMode is known (gap-x-*/gap-y-* must be checked before gap-*) - else if (token.startsWith("gap-y-")) { - gapY = resolveSpacing(token.slice(6)); - } else if (token.startsWith("gap-x-")) { - gapX = resolveSpacing(token.slice(6)); - } else if (token.startsWith("gap-")) { - const val = token.slice(4); - const px = resolveSpacing(val); - if (px !== undefined) gap = px; - } - - // Padding - else if (token.startsWith("p-") && !token.startsWith("pl-") && !token.startsWith("pr-") && !token.startsWith("pt-") && !token.startsWith("pb-") && !token.startsWith("px-") && !token.startsWith("py-")) { - const val = token.slice(2); - const px = resolveSpacing(val); - if (px !== undefined) { - styles.paddingLeft = px; - styles.paddingRight = px; - styles.paddingTop = px; - styles.paddingBottom = px; - } - } else if (token.startsWith("px-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) { - styles.paddingLeft = px; - styles.paddingRight = px; - } - } else if (token.startsWith("py-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) { - styles.paddingTop = px; - styles.paddingBottom = px; - } - } else if (token.startsWith("pl-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) styles.paddingLeft = px; - } else if (token.startsWith("pr-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) styles.paddingRight = px; - } else if (token.startsWith("pt-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) styles.paddingTop = px; - } else if (token.startsWith("pb-")) { - const px = resolveSpacing(token.slice(3)); - if (px !== undefined) styles.paddingBottom = px; - } - - // Min/max width - else if (token.startsWith("min-w-")) { - const px = resolveSpacing(token.slice(6)); - if (px !== undefined) styles.minWidth = px; - } else if (token.startsWith("max-w-")) { - const px = resolveSpacing(token.slice(6)); - if (px !== undefined) styles.maxWidth = px; - } - - // Min/max height - else if (token.startsWith("min-h-")) { - const px = resolveSpacing(token.slice(6)); - if (px !== undefined) styles.minHeight = px; - } else if (token.startsWith("max-h-")) { - const px = resolveSpacing(token.slice(6)); - if (px !== undefined) styles.maxHeight = px; - } - - // Flex wrap - else if (token === "flex-wrap") { - styles.layoutWrap = "WRAP"; - } else if (token === "flex-nowrap") { - styles.layoutWrap = "NO_WRAP"; - } - - // Overflow - else if (token === "overflow-hidden") { - styles.clipsContent = true; - } else if (token === "overflow-x-hidden") { - overflowXHidden = true; - } else if (token === "overflow-y-hidden") { - overflowYHidden = true; - } else if (token === "overflow-x-auto" || token === "overflow-x-scroll") { - overflowX = true; - } else if (token === "overflow-y-auto" || token === "overflow-y-scroll") { - overflowY = true; - } else if (token === "overflow-auto" || token === "overflow-scroll") { - overflowX = true; - overflowY = true; - } - - // Background color → fills - else if (token.startsWith("bg-[")) { - const colorMatch = token.match(/^bg-\[(.+)\]$/); - if (colorMatch?.[1]) { - const color = colorMatch[1]; - if (!styles.fills) styles.fills = []; - if (color.startsWith("var(")) { - // Design token reference: bg-[var(--md-sys-color-surface)] - styles.fills.push({ type: "SOLID", color: {}, boundVariable: color }); - } else { - // Raw color: bg-[#FF0000] - styles.fills.push({ type: "SOLID", color: parseHexColor(color) }); - } - } - } - - // Shadow → effects - else if (token === "shadow" || token.startsWith("shadow-")) { - if (!styles.effects) styles.effects = []; - styles.effects.push({ type: "DROP_SHADOW" }); - } - } - - // Resolve gap based on final layout direction - // Generic gap-* applies to both axes, directional gap-x-*/gap-y-* override specific axes - // In flex-row (HORIZONTAL): gap-x → itemSpacing (main), gap-y → counterAxisSpacing (cross) - // In flex-col (VERTICAL): gap-y → itemSpacing (main), gap-x → counterAxisSpacing (cross) - const isColumn = styles.layoutMode === "VERTICAL"; - if (gap !== undefined) { - if (gapX === undefined) { - if (isColumn) styles.counterAxisSpacing = gap; - else styles.itemSpacing = gap; - } - if (gapY === undefined) { - if (isColumn) styles.itemSpacing = gap; - else styles.counterAxisSpacing = gap; - } - } - if (gapX !== undefined) { - if (isColumn) styles.counterAxisSpacing = gapX; - else styles.itemSpacing = gapX; - } - if (gapY !== undefined) { - if (isColumn) styles.itemSpacing = gapY; - else styles.counterAxisSpacing = gapY; - } - - // Resolve overflow direction from collected flags - // Hidden flags suppress scrolling on that axis - const effectiveX = overflowX && !overflowXHidden; - const effectiveY = overflowY && !overflowYHidden; - if (effectiveX && effectiveY) { - styles.overflowDirection = "HORIZONTAL_AND_VERTICAL_SCROLLING"; - } else if (effectiveX) { - styles.overflowDirection = "HORIZONTAL_SCROLLING"; - } else if (effectiveY) { - styles.overflowDirection = "VERTICAL_SCROLLING"; - } - // Axis-specific hidden implies clipping on that axis - if (overflowXHidden || overflowYHidden) { - styles.clipsContent = true; - } - - return styles; -} - -function parseHexColor(hex: string): Record { - const clean = hex.replace("#", ""); - if (clean.length === 6 || clean.length === 8) { - return { - r: parseInt(clean.slice(0, 2), 16) / 255, - g: parseInt(clean.slice(2, 4), 16) / 255, - b: parseInt(clean.slice(4, 6), 16) / 255, - a: clean.length === 8 ? parseInt(clean.slice(6, 8), 16) / 255 : 1, - }; - } - return { r: 0, g: 0, b: 0, a: 1 }; -} - -/** - * Extract className values from the root-level JSX element in a code block. - * Returns classes from the first/outermost div/element's className. - */ -function extractRootClasses(code: string): string { - // Match the first className="..." in the JSX - const match = code.match(/className="([^"]+)"/); - return match?.[1] ?? ""; -} - -/** - * Extract all className values from entire code block. - * Returns aggregated style info for all elements. - */ -function extractAllClasses(code: string): string[] { - const classes: string[] = []; - const regex = /className="([^"]+)"/g; - let match: RegExpExecArray | null; - while ((match = regex.exec(code)) !== null) { - if (match[1]) classes.push(match[1]); - } - return classes; -} - -/** - * Parse a get_design_context code response to extract style hints - * for enriching AnalysisNode properties. - * - * The code is React+Tailwind generated by Figma MCP. We extract: - * - Root element styles (layoutMode, padding, gap, sizing) - * - Color fills (bg-[#hex] or bg-[var(...)]) - * - Shadow effects - * - Layout positioning (absolute/relative) - */ -export function parseDesignContextCode(code: string): ExtractedStyles { - const rootClasses = extractRootClasses(code); - const rootStyles = extractStylesFromClasses(rootClasses); - - // Also scan all elements for fills and effects to capture deeper style info - const allClasses = extractAllClasses(code); - const aggregatedFills: unknown[] = [...(rootStyles.fills ?? [])]; - const aggregatedEffects: unknown[] = [...(rootStyles.effects ?? [])]; - - for (const cls of allClasses) { - const styles = extractStylesFromClasses(cls); - if (styles.fills) { - for (const fill of styles.fills) aggregatedFills.push(fill); - } - if (styles.effects) { - for (const effect of styles.effects) aggregatedEffects.push(effect); - } - } - - return { - ...rootStyles, - ...(aggregatedFills.length > 0 ? { fills: aggregatedFills } : {}), - ...(aggregatedEffects.length > 0 ? { effects: aggregatedEffects } : {}), - }; -} - -/** - * Parse the comment header from get_design_context code to extract node metadata. - * Format: "/* NodeName — 905x680 COMPONENT, vertical auto-layout *\/" - */ -export function parseCodeHeader(code: string): { - name?: string; - width?: number; - height?: number; - type?: string; - hasAutoLayout?: boolean; - layoutDirection?: "HORIZONTAL" | "VERTICAL"; -} { - const headerMatch = code.match(/\/\*\s*(.+?)\s*\*\//); - if (!headerMatch?.[1]) return {}; - - const header = headerMatch[1]; - const result: ReturnType = {}; - - // Extract name (before " — ") - const nameParts = header.split(" — "); - if (nameParts[0]) result.name = nameParts[0].trim(); - - // Extract dimensions - const dimMatch = header.match(/(\d+)x(\d+)/); - if (dimMatch?.[1] && dimMatch[2]) { - result.width = parseInt(dimMatch[1]); - result.height = parseInt(dimMatch[2]); - } - - // Extract type - const typeMatch = header.match(/\b(COMPONENT|FRAME|INSTANCE|GROUP|SECTION)\b/); - if (typeMatch?.[1]) result.type = typeMatch[1]; - - // Auto-layout detection - if (header.includes("no auto-layout")) { - result.hasAutoLayout = false; - } else if (header.includes("auto-layout")) { - result.hasAutoLayout = true; - if (header.includes("vertical auto-layout")) result.layoutDirection = "VERTICAL"; - else if (header.includes("horizontal auto-layout")) result.layoutDirection = "HORIZONTAL"; - } - - return result; -} - -/** - * Merge extracted styles into an AnalysisNode (mutates the node). - * Only sets properties that are not already present on the node. - */ -export function enrichNodeWithStyles(node: AnalysisNode, styles: ExtractedStyles): void { - if (styles.layoutMode && !node.layoutMode) node.layoutMode = styles.layoutMode; - if (styles.layoutPositioning && !node.layoutPositioning) node.layoutPositioning = styles.layoutPositioning; - if (styles.layoutSizingHorizontal && !node.layoutSizingHorizontal) node.layoutSizingHorizontal = styles.layoutSizingHorizontal; - if (styles.layoutSizingVertical && !node.layoutSizingVertical) node.layoutSizingVertical = styles.layoutSizingVertical; - if (styles.itemSpacing !== undefined && node.itemSpacing === undefined) node.itemSpacing = styles.itemSpacing; - if (styles.paddingLeft !== undefined && node.paddingLeft === undefined) node.paddingLeft = styles.paddingLeft; - if (styles.paddingRight !== undefined && node.paddingRight === undefined) node.paddingRight = styles.paddingRight; - if (styles.paddingTop !== undefined && node.paddingTop === undefined) node.paddingTop = styles.paddingTop; - if (styles.paddingBottom !== undefined && node.paddingBottom === undefined) node.paddingBottom = styles.paddingBottom; - if (styles.fills && !node.fills) node.fills = styles.fills; - if (styles.effects && !node.effects) node.effects = styles.effects; - - // Responsive fields - if (styles.minWidth !== undefined && node.minWidth === undefined) node.minWidth = styles.minWidth; - if (styles.maxWidth !== undefined && node.maxWidth === undefined) node.maxWidth = styles.maxWidth; - if (styles.minHeight !== undefined && node.minHeight === undefined) node.minHeight = styles.minHeight; - if (styles.maxHeight !== undefined && node.maxHeight === undefined) node.maxHeight = styles.maxHeight; - if (styles.layoutWrap && !node.layoutWrap) node.layoutWrap = styles.layoutWrap; - if (styles.counterAxisSpacing !== undefined && node.counterAxisSpacing === undefined) node.counterAxisSpacing = styles.counterAxisSpacing; - if (styles.clipsContent !== undefined && node.clipsContent === undefined) node.clipsContent = styles.clipsContent; - if (styles.overflowDirection && !node.overflowDirection) node.overflowDirection = styles.overflowDirection; -} diff --git a/src/core/engine/design-data-parser.test.ts b/src/core/engine/design-data-parser.test.ts deleted file mode 100644 index d862c813..00000000 --- a/src/core/engine/design-data-parser.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { parseDesignData } from "./design-data-parser.js"; - -// Mock the adapter dependencies -vi.mock("../adapters/figma-mcp-adapter.js", () => ({ - parseMcpMetadataXml: vi.fn( - (xml: string, fileKey: string, fileName?: string) => ({ - fileKey, - name: fileName ?? "mcp-file", - lastModified: "2024-01-01T00:00:00Z", - version: "1", - document: { id: "0:0", name: "Document", type: "DOCUMENT", visible: true }, - components: {}, - styles: {}, - }) - ), -})); - -vi.mock("../adapters/figma-transformer.js", () => ({ - transformFigmaResponse: vi.fn((fileKey: string, response: unknown) => ({ - fileKey, - name: (response as Record)["name"], - lastModified: "2024-01-01T00:00:00Z", - version: "1", - document: { id: "0:0", name: "Document", type: "DOCUMENT", visible: true }, - components: {}, - styles: {}, - })), -})); - -describe("parseDesignData", () => { - it("detects XML input and calls parseMcpMetadataXml", () => { - const xml = ''; - const result = parseDesignData(xml, "abc123", "MyFile"); - - expect(result.fileKey).toBe("abc123"); - expect(result.name).toBe("MyFile"); - }); - - it("detects XML with leading whitespace", () => { - const xml = ' \n '; - const result = parseDesignData(xml, "key1"); - - expect(result.fileKey).toBe("key1"); - }); - - it("detects AnalysisFile JSON (has fileKey + document)", () => { - const analysisFile = { - fileKey: "existing-key", - name: "My Design", - lastModified: "2024-01-01", - version: "1", - document: { id: "0:0", name: "Doc", type: "DOCUMENT" }, - components: {}, - styles: {}, - }; - const data = JSON.stringify(analysisFile); - - const result = parseDesignData(data, "override-key"); - - // Should return as-is since it has fileKey + document - expect(result.fileKey).toBe("existing-key"); - expect(result.name).toBe("My Design"); - }); - - it("detects Figma REST API response (has document + name but no fileKey)", () => { - const apiResponse = { - name: "API Design", - lastModified: "2024-01-01", - version: "1", - document: { id: "0:0", name: "Doc", type: "DOCUMENT" }, - components: {}, - styles: {}, - }; - const data = JSON.stringify(apiResponse); - - const result = parseDesignData(data, "api-key"); - - // Should call transformFigmaResponse which uses our mock - expect(result.fileKey).toBe("api-key"); - expect(result.name).toBe("API Design"); - }); - - it("throws for unrecognized JSON format (no document)", () => { - const badData = JSON.stringify({ foo: "bar", baz: 123 }); - - expect(() => parseDesignData(badData, "key")).toThrow( - "Unrecognized designData format" - ); - }); - - it("throws for invalid JSON string", () => { - expect(() => parseDesignData("not json at all {{{", "key")).toThrow(); - }); - - it("throws for empty string", () => { - expect(() => parseDesignData("", "key")).toThrow(); - }); -}); diff --git a/src/core/engine/design-data-parser.ts b/src/core/engine/design-data-parser.ts deleted file mode 100644 index 7cfb157e..00000000 --- a/src/core/engine/design-data-parser.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { GetFileResponse } from "@figma/rest-api-spec"; -import type { AnalysisFile } from "../contracts/figma-node.js"; -import { parseMcpMetadataXml } from "../adapters/figma-mcp-adapter.js"; -import { transformFigmaResponse } from "../adapters/figma-transformer.js"; - -/** - * Parse design data passed directly from Figma MCP or other sources. - * - * Accepts: - * - XML string from Figma MCP get_metadata - * - JSON string of an AnalysisFile object - * - JSON string of a Figma REST API GetFileResponse - */ -export function parseDesignData( - data: string, - fileKey: string, - fileName?: string -): AnalysisFile { - const trimmed = data.trim(); - - // Detect XML (starts with <) - if (trimmed.startsWith("<")) { - return parseMcpMetadataXml(trimmed, fileKey, fileName); - } - - // Try JSON - const parsed = JSON.parse(trimmed) as Record; - - // If it already looks like AnalysisFile (has fileKey + document) - if ("fileKey" in parsed && "document" in parsed) { - return parsed as unknown as AnalysisFile; - } - - // If it looks like Figma REST API response (has document + name but no fileKey) - if ("document" in parsed && "name" in parsed) { - return transformFigmaResponse(fileKey, parsed as unknown as GetFileResponse); - } - - throw new Error( - "Unrecognized designData format. Expected XML from Figma MCP get_metadata, AnalysisFile JSON, or Figma REST API JSON." - ); -} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a841025e..3f201370 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,9 +7,7 @@ import { z } from "zod"; import { writeFile } from "node:fs"; import { exec } from "node:child_process"; import { analyzeFile } from "../core/engine/rule-engine.js"; -import { loadFile } from "../core/engine/loader.js"; -import { parseDesignData } from "../core/engine/design-data-parser.js"; -import { enrichWithDesignContext } from "../core/adapters/figma-mcp-adapter.js"; +import { loadFile, isJsonFile, isFixtureDir } from "../core/engine/loader.js"; import { calculateScores, buildResultJson } from "../core/engine/scoring.js"; import { generateHtmlReport } from "../core/report-html/index.js"; import { getReportsDir, ensureReportsDir } from "../core/engine/config-store.js"; @@ -39,30 +37,9 @@ server.tool( "analyze", `Analyze a Figma design for development-friendliness and AI-friendliness. -Two ways to provide design data: -1. designData — Pass Figma node data directly (from Figma MCP get_metadata). Recommended when using Figma MCP. -2. input — Figma URL (fetches via REST API, requires FIGMA_TOKEN). - -Typical flow with Figma MCP (recommended, no token needed): - Step 1: Call the official Figma MCP's get_metadata tool to get the node tree - Step 2: Call the official Figma MCP's get_design_context tool on the same node to get style data - Step 3: Pass get_metadata result as designData and get_design_context code as designContext to this tool - -The designContext parameter enriches analysis with style information (colors, layout, spacing, effects) -that get_metadata alone cannot provide. Without it, token and layout rules may not fire. - -IMPORTANT — Before calling this tool, check which data source is available: -- If the official Figma MCP (https://mcp.figma.com/mcp) is connected: use get_metadata + get_design_context → designData + designContext flow. No token needed. -- If Figma MCP is NOT connected: use the input parameter with a Figma URL. This requires a FIGMA_TOKEN. - Tell the user: "The official Figma MCP server is not connected. To use without a token, set it up: - claude mcp add -s project -t http figma https://mcp.figma.com/mcp - Otherwise, provide a Figma API token via FIGMA_TOKEN env var or the token parameter."`, +Provide a Figma URL or fixture path via the input parameter. Requires FIGMA_TOKEN env var or the token parameter for live Figma URLs.`, { - designData: z.string().optional().describe("Figma node data from Figma MCP get_metadata (XML or JSON). Pass this instead of input when using Figma MCP."), - designContext: z.string().optional().describe("Code output from Figma MCP get_design_context. Enriches designData with style info (colors, layout, spacing, effects). Highly recommended alongside designData."), - input: z.string().optional().describe("Figma URL. Used when designData is not provided. Requires FIGMA_TOKEN."), - fileKey: z.string().optional().describe("Figma file key (used with designData to generate deep links)"), - fileName: z.string().optional().describe("Figma file name (used with designData for display)"), + input: z.string().describe("Figma URL or local fixture path. Requires FIGMA_TOKEN for live URLs."), token: z.string().optional().describe("Figma API token (falls back to FIGMA_TOKEN env var)"), preset: z.enum(["relaxed", "dev-friendly", "ai-ready", "strict"]).optional().describe("Analysis preset"), targetNodeId: z.string().optional().describe("Scope analysis to a specific node ID"), @@ -74,28 +51,11 @@ IMPORTANT — Before calling this tool, check which data source is available: openWorldHint: true, title: "Analyze Figma Design", }, - async ({ designData, designContext, input, fileKey, fileName, token, preset, targetNodeId, configPath }) => { + async ({ input, token, preset, targetNodeId, configPath }) => { trackEvent(EVENTS.MCP_TOOL_CALLED, { tool: "analyze" }); try { - let file; - let nodeId: string | undefined; - - if (designData) { - // Direct data from Figma MCP - file = parseDesignData(designData, fileKey ?? "unknown", fileName); - - // Enrich with design context if provided - if (designContext) { - enrichWithDesignContext(file, designContext, targetNodeId); - } - } else if (input) { - // Fetch via REST API or load from fixture - const loaded = await loadFile(input, token); - file = loaded.file; - nodeId = loaded.nodeId; - } else { - throw new Error("Provide either designData (from Figma MCP) or input (Figma URL)."); - } + // Fetch via REST API or load from fixture + const { file, nodeId } = await loadFile(input, token); const effectiveNodeId = targetNodeId ?? nodeId; @@ -140,7 +100,7 @@ IMPORTANT — Before calling this tool, check which data source is available: issueCount: result.issues.length, grade: scores.overall.grade, percentage: scores.overall.percentage, - source: designData ? "mcp-data" : "url", + source: isJsonFile(input) || isFixtureDir(input) ? "fixture" : "figma", }); return { @@ -263,25 +223,10 @@ Get your token: Figma → Settings → Security → Personal access tokens → G ## MCP Server (Claude Code / Cursor / Claude Desktop) \`\`\`bash -claude mcp add canicode -- npx -y -p canicode canicode-mcp -claude mcp add -s project -t http figma https://mcp.figma.com/mcp -\`\`\` - -With Figma API token (no Figma MCP needed): -\`\`\`bash claude mcp add canicode -e FIGMA_TOKEN=figd_xxxxxxxxxxxxx -- npx -y -p canicode canicode-mcp \`\`\` -## CLI vs MCP - -| Feature | CLI (FIGMA_TOKEN) | MCP (Figma MCP) | -|---------|:-:|:-:| -| Component master trees | ✅ | ❌ | -| Component metadata | ✅ | ❌ | -| Annotations (dev mode) | ❌ private beta | ✅ data-annotations | -| FIGMA_TOKEN required | ✅ | ❌ | - -Use CLI for accurate component analysis. Use MCP for quick checks and annotation-aware workflows.`, +Requires FIGMA_TOKEN for live Figma URL analysis.`, "visual-compare": `# Visual Compare