diff --git a/.claude/skills/canicode-implement/SKILL.md b/.claude/skills/canicode-implement/SKILL.md new file mode 100644 index 00000000..67974352 --- /dev/null +++ b/.claude/skills/canicode-implement/SKILL.md @@ -0,0 +1,68 @@ +--- +name: canicode-implement +description: Prepare a design-to-code implementation package from a Figma URL or fixture +--- + +# CanICode Implement -- Design-to-Code Package + +Prepare everything an AI needs to implement a Figma design as code: analysis report, design tree with image assets, and a code generation prompt. + +This skill does NOT auto-generate code. It assembles a package that you then feed to an AI coding assistant. + +## Prerequisites + +One of: +- **FIGMA_TOKEN** environment variable for REST API access +- **Local fixture** directory (no token needed) + +## Usage + +### From a local fixture (simplest) + +```bash +npx canicode implement ./fixtures/my-design +``` + +### From a Figma URL + +```bash +npx canicode implement "https://www.figma.com/design/ABC/File?node-id=1-234" +``` + +### With a custom prompt (for your stack) + +```bash +npx canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md +``` + +The default prompt generates HTML+CSS. Write your own prompt for React, Vue, or any other stack. + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--prompt ` | Custom prompt file for your stack | Built-in HTML+CSS | +| `--output ` | Output directory | `./canicode-implement/` | +| `--token ` | Figma API token | `FIGMA_TOKEN` env var | +| `--image-scale ` | Image export scale (1-4) | `2` (PC), use `3` for mobile | + +## Output Structure + +```text +canicode-implement/ + analysis.json # Full analysis with issues and scores + design-tree.txt # DOM-like tree with styles, structure, embedded SVGs + PROMPT.md # Code generation prompt (default or custom) + screenshot.png # Figma screenshot (if available) + vectors/ # SVG assets for VECTOR nodes + images/ # PNG assets for IMAGE fill nodes (hero-banner@2x.png) +``` + +## Next Steps + +After running `canicode implement`: + +1. Open `design-tree.txt` -- this is the primary input for the AI +2. Open `PROMPT.md` -- this contains the coding conventions +3. Feed both to your AI coding assistant along with any images from `images/` and `vectors/` +4. Review `analysis.json` for known design issues that may affect implementation diff --git a/.claude/skills/design-to-code/PROMPT.md b/.claude/skills/design-to-code/PROMPT.md index 07f7951e..222956fb 100644 --- a/.claude/skills/design-to-code/PROMPT.md +++ b/.claude/skills/design-to-code/PROMPT.md @@ -3,7 +3,7 @@ This prompt is used by all code generation pipelines: - Calibration Converter - Rule Discovery A/B Validation -- design-to-code GitHub Action (via `canicode prompt`) +- User-facing `canicode implement` command (default prompt) ## Stack - HTML + CSS (single file) @@ -28,6 +28,10 @@ Your job is to translate the Figma data to HTML+CSS — nothing more. - Do NOT add overflow: auto or scroll unless specified - Fonts: load via Google Fonts CDN (`` tag). Do NOT use system font fallbacks as primary — the exact font from the data must render. +### Image Assets +- If the design tree shows `background-image: url(images/...)`, use that path directly +- If it shows `background-image: [IMAGE]`, the image asset is unavailable — use a placeholder color + ### If data is missing When the Figma data does not specify a value, you MUST list it as an interpretation. Do not silently guess — always declare what you assumed. diff --git a/CLAUDE.md b/CLAUDE.md index 86cdb2cd..de489e95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,7 @@ app/ # Browser runtime - Output: HTML report (opens in browser) - Options: `--preset`, `--token`, `--output`, `--custom-rules`, `--config` - Also: `canicode save-fixture` to save Figma data as JSON for offline analysis +- Also: `canicode implement` to prepare a design-to-code package (analysis + design tree + assets + prompt) - Component master resolution: fetches `componentDefinitions` for accurate component analysis - Annotations: NOT available (REST API annotations field is private beta) diff --git a/README.md b/README.md index 1659c7b6..d5383e97 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ The rules themselves run deterministically on every analysis — no tokens consu | Just try it | **[Web App](https://let-sunny.github.io/canicode/)** — paste a URL, no install | | Analyze inside Figma | **[Figma Plugin](https://www.figma.com/community/plugin/1617144221046795292/canicode)** (under review) | | Use with Claude Code / Cursor | **MCP Server** or **Skill** — see below | +| Generate code from design | **`canicode implement`** — analysis + design tree + assets + prompt | | Add to CI/CD | **[GitHub Action](https://github.com/marketplace/actions/canicode-action)** | | Full control | **CLI** | @@ -113,6 +114,31 @@ Hitting 429 errors? Make sure the file is in a paid workspace. Or use MCP (no to +
+Design to Code (prepare implementation package) + +```bash +canicode implement ./fixtures/my-design +canicode implement "https://www.figma.com/design/ABC/File?node-id=1-234" --prompt ./my-react-prompt.md --image-scale 3 +``` + +Outputs a ready-to-use package for AI code generation: +- `analysis.json` — issues + scores +- `design-tree.txt` — DOM-like tree with CSS styles + token estimate +- `images/` — PNG assets with human-readable names (`hero-banner@2x.png`) +- `vectors/` — SVG assets +- `PROMPT.md` — code generation prompt (default: HTML+CSS, or your custom prompt) + +| Option | Default | Description | +|--------|---------|-------------| +| `--prompt` | built-in HTML+CSS | Path to your custom prompt file for any stack | +| `--image-scale` | `2` | Image export scale: `2` for PC, `3` for mobile | +| `--output` | `./canicode-implement/` | Output directory | + +Feed `design-tree.txt` + `PROMPT.md` to your AI assistant (Claude, Cursor, etc.) to generate code. + +
+
MCP Server (Claude Code / Cursor / Claude Desktop) diff --git a/src/cli/docs.ts b/src/cli/docs.ts index b5c40e78..b6f1a0ca 100644 --- a/src/cli/docs.ts +++ b/src/cli/docs.ts @@ -12,9 +12,10 @@ export function printDocsIndex(): void { console.log(` CANICODE DOCUMENTATION (v${pkg.version}) - canicode docs setup Full setup guide (CLI, MCP, Skills) - canicode docs rules Custom rules guide + example - canicode docs config Config override guide + example + canicode docs setup Full setup guide (CLI, MCP, Skills) + canicode docs rules Custom rules guide + example + canicode docs config Config override guide + example + canicode docs implement Design-to-code package guide Full documentation: github.com/let-sunny/canicode#readme `.trimStart()); @@ -271,11 +272,56 @@ USE CASES `.trimStart()); } +/** Print the implement command guide. */ +export function printDocsImplement(): void { + console.log(` +DESIGN-TO-CODE IMPLEMENTATION GUIDE + +Prepare everything an AI needs to implement a Figma design as code. + +USAGE + canicode implement [options] + +OPTIONS + --prompt Custom prompt file (default: built-in HTML+CSS) + --image-scale Image export scale: 2 for PC (default), 3 for mobile + --output Output directory (default: ./canicode-implement/) + --token Figma API token (for live URLs) + +OUTPUT + canicode-implement/ + analysis.json Analysis report with issues and scores + design-tree.txt DOM-like tree with CSS styles (~N tokens) + images/ PNG assets with human-readable names (hero-banner@2x.png) + vectors/ SVG assets for vector nodes + PROMPT.md Stack-specific code generation prompt + +WORKFLOW + 1. Run: canicode implement ./my-fixture --prompt ./my-react-prompt.md + 2. Feed design-tree.txt + PROMPT.md to your AI assistant + 3. AI generates code matching the design pixel-perfectly + 4. Verify with: canicode visual-compare ./output.html --figma-url + +CUSTOM PROMPT + Default prompt generates HTML+CSS. For your own stack: + 1. Write a prompt file (e.g. my-react-prompt.md) + 2. Pass it: canicode implement ./fixture --prompt ./my-react-prompt.md + The design-tree.txt format is stack-agnostic — your prompt just needs + to describe how to convert it to your target framework. + +IMAGE SCALE + --image-scale 2 PC/desktop (default) — @2x retina + --image-scale 3 Mobile — @3x retina + SVG vectors are scale-independent and always included. +`.trimStart()); +} + const DOCS_TOPICS: Record void> = { setup: printDocsSetup, install: printDocsSetup, // alias rules: printDocsRules, config: printDocsConfig, + implement: printDocsImplement, "visual-compare": printDocsVisualCompare, "design-tree": printDocsDesignTree, }; diff --git a/src/cli/index.ts b/src/cli/index.ts index 73c528c6..09485b1d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,7 +10,7 @@ import cac from "cac"; config(); import { parseFigmaUrl } from "../core/adapters/figma-url-parser.js"; -import type { AnalysisFile } from "../core/contracts/figma-node.js"; +import type { AnalysisFile, AnalysisNode } from "../core/contracts/figma-node.js"; import type { RuleConfig, RuleId } from "../core/contracts/rule.js"; import { analyzeFile } from "../core/engine/rule-engine.js"; import { loadFile, isFigmaUrl, isJsonFile, isFixtureDir } from "../core/engine/loader.js"; @@ -102,6 +102,33 @@ function collectVectorNodeIds(node: { id: string; type: string; children?: reado return ids; } +function collectImageNodes(node: AnalysisNode): Array<{ id: string; name: string }> { + const nodes: Array<{ id: string; name: string }> = []; + function walk(n: AnalysisNode): void { + if (n.fills && Array.isArray(n.fills)) { + for (const fill of n.fills) { + if ((fill as { type?: string }).type === "IMAGE") { + nodes.push({ id: n.id, name: n.name }); + break; + } + } + } + if (n.children) { + for (const child of n.children) walk(child); + } + } + walk(node); + return nodes; +} + +function sanitizeFilename(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + || "image"; +} + function countNodes(node: { children?: readonly unknown[] | undefined }): number { let count = 1; if (node.children) { @@ -735,6 +762,7 @@ interface SaveFixtureOptions { output?: string; api?: boolean; token?: string; + imageScale?: string; } cli @@ -745,8 +773,9 @@ cli .option("--output ", "Output directory (default: fixtures//)") .option("--name ", "Fixture name (default: extracted from URL)") .option("--token ", "Figma API token (or use FIGMA_TOKEN env var)") + .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234") - .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --name my-design") + .example(" canicode save-fixture https://www.figma.com/design/ABC123/MyDesign?node-id=1-234 --image-scale 3") .action(async (input: string, options: SaveFixtureOptions & { name?: string }) => { try { if (!isFigmaUrl(input)) { @@ -837,6 +866,64 @@ cli } console.log(` vectors/: ${downloaded}/${vectorNodeIds.length} SVGs`); } + + // 4. Download PNGs for IMAGE fill nodes + const imageNodes = collectImageNodes(file.document); + if (imageNodes.length > 0) { + const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; + if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + + const imageDir = resolve(fixtureDir, "images"); + mkdirSync(imageDir, { recursive: true }); + + const imageUrls = await client.getNodeImages( + file.fileKey, + imageNodes.map((n) => n.id), + { format: "png", scale: imgScale } + ); + + const usedNames = new Map(); + const nodeIdToFilename = new Map(); + for (const { id, name } of imageNodes) { + let base = sanitizeFilename(name); + const count = usedNames.get(base) ?? 0; + usedNames.set(base, count + 1); + if (count > 0) base = `${base}-${count + 1}`; + nodeIdToFilename.set(id, `${base}@${imgScale}x.png`); + } + + let imgDownloaded = 0; + for (const [id, imgUrl] of Object.entries(imageUrls)) { + if (!imgUrl) continue; + const filename = nodeIdToFilename.get(id); + if (!filename) continue; + try { + const resp = await fetch(imgUrl); + if (resp.ok) { + const buf = Buffer.from(await resp.arrayBuffer()); + await writeFile(resolve(imageDir, filename), buf); + imgDownloaded++; + } + } catch { + // Skip failed downloads + } + } + + const mapping: Record = {}; + for (const [id, filename] of nodeIdToFilename) { + mapping[id] = filename; + } + await writeFile( + resolve(imageDir, "mapping.json"), + JSON.stringify(mapping, null, 2), + "utf-8" + ); + + console.log(` images/: ${imgDownloaded}/${imageNodes.length} PNGs (@${imgScale}x)`); + } } } catch (error) { console.error( @@ -859,24 +946,35 @@ cli .option("--token ", "Figma API token (or use FIGMA_TOKEN env var)") .option("--output ", "Output file path (default: stdout)") .option("--vector-dir ", "Directory with SVG files for VECTOR nodes (auto-detected from fixture path)") + .option("--image-dir ", "Directory with image PNGs for IMAGE fill nodes (auto-detected from fixture path)") .example(" canicode design-tree ./fixtures/my-design") .example(" canicode design-tree https://www.figma.com/design/ABC/File?node-id=1-234 --output tree.txt") - .action(async (input: string, options: { token?: string; output?: string; vectorDir?: string }) => { + .action(async (input: string, options: { token?: string; output?: string; vectorDir?: string; imageDir?: string }) => { try { const { file } = await loadFile(input, options.token); + const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input); + // Auto-detect vector dir from fixture path let vectorDir = options.vectorDir; if (!vectorDir) { - // fixtures/name/data.json → fixtures/name/vectors/ - // fixtures/name/ → fixtures/name/vectors/ - const fixtureBase = isJsonFile(input) ? dirname(resolve(input)) : resolve(input); const autoDir = resolve(fixtureBase, "vectors"); if (existsSync(autoDir)) vectorDir = autoDir; } + // Auto-detect image dir from fixture path + let imageDir = options.imageDir; + if (!imageDir) { + const autoDir = resolve(fixtureBase, "images"); + if (existsSync(autoDir)) imageDir = autoDir; + } + const { generateDesignTreeWithStats } = await import("../core/engine/design-tree.js"); - const stats = generateDesignTreeWithStats(file, vectorDir ? { vectorDir } : undefined); + const treeOptions = { + ...(vectorDir ? { vectorDir } : {}), + ...(imageDir ? { imageDir } : {}), + }; + const stats = generateDesignTreeWithStats(file, treeOptions); if (options.output) { const outputDir = dirname(resolve(options.output)); @@ -893,6 +991,248 @@ cli } }); +// ============================================ +// Implement command — design-to-code package +// ============================================ + +interface ImplementOptions { + token?: string; + output?: string; + prompt?: string; + imageScale?: string; +} + +cli + .command( + "implement ", + "Prepare design-to-code package: analysis + design tree + assets + prompt" + ) + .option("--token ", "Figma API token (or use FIGMA_TOKEN env var)") + .option("--output ", "Output directory (default: ./canicode-implement/)") + .option("--prompt ", "Custom prompt file (default: built-in HTML+CSS prompt)") + .option("--image-scale ", "Image export scale: 2 for PC (default), 3 for mobile") + .example(" canicode implement ./fixtures/my-design") + .example(" canicode implement ./fixtures/my-design --prompt ./my-react-prompt.md --image-scale 3") + .action(async (input: string, options: ImplementOptions) => { + try { + + const outputDir = resolve(options.output ?? "canicode-implement"); + mkdirSync(outputDir, { recursive: true }); + + console.log("\nPreparing implementation package...\n"); + + // 1. Load file + const { file } = await loadFile(input, options.token); + console.log(`Design: ${file.name}`); + + // 2. Analysis + const result = analyzeFile(file); + const scores = calculateScores(result); + const resultJson = buildResultJson(file.name, result, scores); + await writeFile(resolve(outputDir, "analysis.json"), JSON.stringify(resultJson, null, 2), "utf-8"); + console.log(` analysis.json: ${result.issues.length} issues, grade ${scores.overall.grade}`); + + // 3. Prepare assets (before design tree, so tree can reference image paths) + const fixtureBase = (isJsonFile(input) || isFixtureDir(input)) + ? (isJsonFile(input) ? dirname(resolve(input)) : resolve(input)) + : undefined; + + let vectorDir = fixtureBase ? resolve(fixtureBase, "vectors") : undefined; + let imageDir = fixtureBase ? resolve(fixtureBase, "images") : undefined; + + // Copy fixture assets to output + if (vectorDir && existsSync(vectorDir)) { + const vecOutputDir = resolve(outputDir, "vectors"); + mkdirSync(vecOutputDir, { recursive: true }); + const { readdirSync, copyFileSync } = await import("node:fs"); + const vecFiles = readdirSync(vectorDir).filter(f => f.endsWith(".svg")); + for (const f of vecFiles) { + copyFileSync(resolve(vectorDir, f), resolve(vecOutputDir, f)); + } + console.log(` vectors/: ${vecFiles.length} SVGs copied`); + } + + if (imageDir && existsSync(imageDir)) { + const imgOutputDir = resolve(outputDir, "images"); + mkdirSync(imgOutputDir, { recursive: true }); + const { readdirSync, copyFileSync } = await import("node:fs"); + const imgFiles = readdirSync(imageDir).filter(f => f.endsWith(".png") || f.endsWith(".jpg") || f.endsWith(".json")); + for (const f of imgFiles) { + copyFileSync(resolve(imageDir, f), resolve(imgOutputDir, f)); + } + const pngCount = imgFiles.filter(f => f.endsWith(".png")).length; + console.log(` images/: ${pngCount} assets copied`); + } + + // Download assets from Figma API for live URLs + if (isFigmaUrl(input) && !fixtureBase) { + const figmaToken = options.token ?? getFigmaToken(); + if (figmaToken) { + const imgScale = options.imageScale !== undefined ? Number(options.imageScale) : 2; + if (!Number.isFinite(imgScale) || imgScale < 1 || imgScale > 4) { + console.error("Error: --image-scale must be 1-4 (2 for PC, 3 for mobile)"); + process.exit(1); + } + + const { FigmaClient } = await import("../core/adapters/figma-client.js"); + const client = new FigmaClient({ token: figmaToken }); + + // Download screenshot + const { nodeId } = parseFigmaUrl(input); + const rootNodeId = nodeId?.replace(/-/g, ":") ?? file.document.id; + try { + const screenshotUrls = await client.getNodeImages(file.fileKey, [rootNodeId], { format: "png", scale: 2 }); + const screenshotUrl = screenshotUrls[rootNodeId]; + if (screenshotUrl) { + const resp = await fetch(screenshotUrl); + if (resp.ok) { + const buf = Buffer.from(await resp.arrayBuffer()); + await writeFile(resolve(outputDir, "screenshot.png"), buf); + console.log(` screenshot.png: saved`); + } + } + } catch { + console.warn(" screenshot.png: failed to download (continuing)"); + } + + // Download vector SVGs + const vectorNodeIds = collectVectorNodeIds(file.document); + if (vectorNodeIds.length > 0) { + const vecOutDir = resolve(outputDir, "vectors"); + mkdirSync(vecOutDir, { recursive: true }); + try { + const svgUrls = await client.getNodeImages(file.fileKey, vectorNodeIds, { format: "svg" }); + let downloaded = 0; + for (const [id, svgUrl] of Object.entries(svgUrls)) { + if (!svgUrl) continue; + try { + const resp = await fetch(svgUrl); + if (resp.ok) { + const svg = await resp.text(); + const safeId = id.replace(/:/g, "-"); + await writeFile(resolve(vecOutDir, `${safeId}.svg`), svg, "utf-8"); + downloaded++; + } + } catch { /* skip */ } + } + console.log(` vectors/: ${downloaded}/${vectorNodeIds.length} SVGs`); + } catch { + console.warn(" vectors/: failed to download (continuing)"); + } + } + + // Download image PNGs + const imgNodes = collectImageNodes(file.document); + if (imgNodes.length > 0) { + const imgOutDir = resolve(outputDir, "images"); + mkdirSync(imgOutDir, { recursive: true }); + try { + const imgUrls = await client.getNodeImages( + file.fileKey, + imgNodes.map(n => n.id), + { format: "png", scale: imgScale }, + ); + const usedNames = new Map(); + let downloaded = 0; + for (const { id, name } of imgNodes) { + const imgUrl = imgUrls[id]; + if (!imgUrl) continue; + let base = sanitizeFilename(name); + const count = usedNames.get(base) ?? 0; + usedNames.set(base, count + 1); + if (count > 0) base = `${base}-${count + 1}`; + const filename = `${base}@${imgScale}x.png`; + try { + const resp = await fetch(imgUrl); + if (resp.ok) { + const buf = Buffer.from(await resp.arrayBuffer()); + await writeFile(resolve(imgOutDir, filename), buf); + downloaded++; + } + } catch { /* skip */ } + } + // Write mapping.json for design-tree + const mapping: Record = {}; + const usedNamesForMapping = new Map(); + for (const { id, name } of imgNodes) { + let base = sanitizeFilename(name); + const cnt = usedNamesForMapping.get(base) ?? 0; + usedNamesForMapping.set(base, cnt + 1); + if (cnt > 0) base = `${base}-${cnt + 1}`; + mapping[id] = `${base}@${imgScale}x.png`; + } + await writeFile(resolve(imgOutDir, "mapping.json"), JSON.stringify(mapping, null, 2), "utf-8"); + + imageDir = imgOutDir; + console.log(` images/: ${downloaded}/${imgNodes.length} PNGs (@${imgScale}x)`); + } catch { + console.warn(" images/: failed to download (continuing)"); + } + } + + // Update vectorDir to point to downloaded assets + const vecOutCheck = resolve(outputDir, "vectors"); + if (existsSync(vecOutCheck)) vectorDir = vecOutCheck; + } + } + + // 4. Design tree (after assets so image paths are available) + const { generateDesignTreeWithStats } = await import("../core/engine/design-tree.js"); + const treeOptions = { + ...(vectorDir && existsSync(vectorDir) ? { vectorDir } : {}), + ...(imageDir && existsSync(imageDir) ? { imageDir } : {}), + }; + const stats = generateDesignTreeWithStats(file, treeOptions); + await writeFile(resolve(outputDir, "design-tree.txt"), stats.tree, "utf-8"); + console.log(` design-tree.txt: ~${stats.estimatedTokens} tokens`); + + // 5. Assemble prompt + if (options.prompt) { + // Custom prompt: copy user's file + const { readFile: rf } = await import("node:fs/promises"); + const customPrompt = await rf(resolve(options.prompt), "utf-8"); + await writeFile(resolve(outputDir, "PROMPT.md"), customPrompt, "utf-8"); + console.log(` PROMPT.md: custom (${options.prompt})`); + } else { + // Default: built-in HTML+CSS prompt + const { readFile: rf } = await import("node:fs/promises"); + const { dirname: dirnameFn, resolve: resolveFn } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const cliDir = dirnameFn(fileURLToPath(import.meta.url)); + const projectRoot = resolveFn(cliDir, "../.."); + const altRoot = resolveFn(cliDir, ".."); + + let prompt = ""; + for (const root of [projectRoot, altRoot]) { + const p = resolveFn(root, ".claude/skills/design-to-code/PROMPT.md"); + try { + prompt = await rf(p, "utf-8"); + break; + } catch { /* try next */ } + } + + if (prompt) { + await writeFile(resolve(outputDir, "PROMPT.md"), prompt, "utf-8"); + console.log(` PROMPT.md: default (html-css)`); + } else { + console.warn(" PROMPT.md: built-in prompt not found (skipped)"); + } + } + + // Summary + console.log(`\n${"=".repeat(50)}`); + console.log(`Implementation package ready: ${outputDir}/`); + console.log(` Grade: ${scores.overall.grade} (${scores.overall.percentage}%)`); + console.log(` Issues: ${result.issues.length}`); + console.log(` Design tree: ~${stats.estimatedTokens} tokens`); + console.log(`${"=".repeat(50)}`); + console.log(`\nNext: Feed design-tree.txt + PROMPT.md to your AI assistant.`); + } catch (error) { + console.error("\nError:", error instanceof Error ? error.message : String(error)); + process.exit(1); + } + }); + // ============================================ // Visual compare command // ============================================ diff --git a/src/core/engine/design-tree.test.ts b/src/core/engine/design-tree.test.ts index 1e273c04..a6c31afe 100644 --- a/src/core/engine/design-tree.test.ts +++ b/src/core/engine/design-tree.test.ts @@ -931,4 +931,89 @@ describe("generateDesignTree", () => { expect(output).not.toContain("svg:"); }); }); + + describe("IMAGE fill with imageDir mapping", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "design-tree-image-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("IMAGE fill with imageDir and mapping outputs url(images/...)", () => { + const imageDir = join(tempDir, "images"); + mkdirSync(imageDir); + writeFileSync( + join(imageDir, "mapping.json"), + JSON.stringify({ "1:2": "hero-banner@2x.png" }) + ); + + const file = makeFile( + makeNode({ + id: "1:1", + name: "Container", + type: "FRAME", + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 300 }, + children: [ + makeNode({ + id: "1:2", + name: "HeroBanner", + type: "FRAME", + fills: [{ type: "IMAGE", scaleMode: "FILL", imageRef: "abc123" }], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 200 }, + }), + ], + }) + ); + + const output = generateDesignTree(file, { imageDir }); + + expect(output).toContain("background-image: url(images/hero-banner@2x.png)"); + expect(output).not.toContain("background-image: [IMAGE]"); + }); + + it("IMAGE fill without imageDir outputs [IMAGE]", () => { + const file = makeFile( + makeNode({ + id: "1:2", + name: "HeroBanner", + type: "FRAME", + fills: [{ type: "IMAGE", scaleMode: "FILL", imageRef: "abc123" }], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 200 }, + }) + ); + + const output = generateDesignTree(file); + + expect(output).toContain("background-image: [IMAGE]"); + expect(output).not.toContain("url(images/"); + }); + + it("IMAGE fill with imageDir but no mapping entry outputs [IMAGE]", () => { + const imageDir = join(tempDir, "images"); + mkdirSync(imageDir); + writeFileSync( + join(imageDir, "mapping.json"), + JSON.stringify({ "99:99": "other-image@2x.png" }) + ); + + const file = makeFile( + makeNode({ + id: "1:2", + name: "HeroBanner", + type: "FRAME", + fills: [{ type: "IMAGE", scaleMode: "FILL", imageRef: "abc123" }], + absoluteBoundingBox: { x: 0, y: 0, width: 400, height: 200 }, + }) + ); + + const output = generateDesignTree(file, { imageDir }); + + expect(output).toContain("background-image: [IMAGE]"); + expect(output).not.toContain("url(images/"); + }); + }); }); diff --git a/src/core/engine/design-tree.ts b/src/core/engine/design-tree.ts index a7c378e7..69e49ed8 100644 --- a/src/core/engine/design-tree.ts +++ b/src/core/engine/design-tree.ts @@ -100,6 +100,7 @@ function renderNode( indent: number, vectorDir?: string, components?: AnalysisFile["components"], + imageMapping?: Record, ): string { if (node.visible === false) return ""; @@ -147,7 +148,14 @@ function renderNode( // Fill (not for TEXT — text fill is color) const fillInfo = getFillInfo(node); if (fillInfo.color && node.type !== "TEXT") styles.push(`background: ${fillInfo.color}`); - if (fillInfo.hasImage) styles.push("background-image: [IMAGE]"); + if (fillInfo.hasImage) { + const mappedFile = imageMapping?.[node.id]; + if (mappedFile) { + styles.push(`background-image: url(images/${mappedFile})`); + } else { + styles.push("background-image: [IMAGE]"); + } + } // Border — respect per-side stroke weights const stroke = getStroke(node); @@ -218,7 +226,7 @@ function renderNode( // Children if (node.children) { for (const child of node.children) { - const childOutput = renderNode(child, indent + 1, vectorDir, components); + const childOutput = renderNode(child, indent + 1, vectorDir, components, imageMapping); if (childOutput) lines.push(childOutput); } } @@ -230,6 +238,8 @@ function renderNode( export interface DesignTreeOptions { /** Directory containing .svg files for VECTOR nodes */ vectorDir?: string; + /** Directory containing downloaded PNGs and mapping.json for IMAGE fill nodes */ + imageDir?: string; } /** @@ -260,7 +270,18 @@ export function generateDesignTreeWithStats(file: AnalysisFile, options?: Design const w = root.absoluteBoundingBox ? Math.round(root.absoluteBoundingBox.width) : 0; const h = root.absoluteBoundingBox ? Math.round(root.absoluteBoundingBox.height) : 0; - const tree = renderNode(root, 0, options?.vectorDir, file.components); + // Load image mapping once if imageDir is provided + let imageMapping: Record | undefined; + if (options?.imageDir) { + const mappingPath = join(options.imageDir, "mapping.json"); + if (existsSync(mappingPath)) { + try { + imageMapping = JSON.parse(readFileSync(mappingPath, "utf-8")) as Record; + } catch { /* ignore malformed mapping */ } + } + } + + const tree = renderNode(root, 0, options?.vectorDir, file.components, imageMapping); const result = [ "# Design Tree",