diff --git a/pi/files/agent/skills/pi-skills/figma-tools/.gitignore b/pi/files/agent/skills/pi-skills/figma-tools/.gitignore new file mode 100644 index 0000000..e2d2049 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +logs/ diff --git a/pi/files/agent/skills/pi-skills/figma-tools/SKILL.md b/pi/files/agent/skills/pi-skills/figma-tools/SKILL.md new file mode 100644 index 0000000..440584e --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/SKILL.md @@ -0,0 +1,141 @@ +--- +name: figma-tools +description: Fetch and parse Figma design files for AI-assisted implementation. Extract layout, styles, components, and assets from Figma URLs. +--- + +# Figma Tools + +Direct Figma API tools for fetching design data. Parse Figma files into simplified, AI-friendly structures. + +## Setup + +```bash +cd {baseDir}/figma-tools +pnpm install +``` + +Set your Figma access token: + +```bash +export FIGMA_API_KEY="your-token-here" +``` + +Get a token from [Figma Settings → Personal Access Tokens](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens). + +## Commands + +### Get File or Node + +```bash +# Get entire file +{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design + +# Get specific frame/node +{figmaDir}/figma-tools get "https://www.figma.com/file/ABC123/My-Design?node-id=123%3A456" + +# Control depth (reduce token count) +{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --depth 2 + +# Output raw Figma API response +{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --raw > raw.json + +# Output as YAML +{figmaDir}/figma-tools get https://www.figma.com/file/ABC123/My-Design --yaml +``` + +### Download Assets + +```bash +# Download all images from a file +{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets + +# Download PNGs at 2x scale +{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets --scale 2 + +# Download SVGs +{figmaDir}/figma-tools download https://www.figma.com/file/ABC123/My-Design ./icons --format svg +``` + +### Parse Local Files + +```bash +# Simplify a raw Figma JSON file +{figmaDir}/figma-tools parse ./raw-figma-response.json + +# Output to file +{figmaDir}/figma-tools parse ./raw.json --yaml > design.yaml +``` + +## When to Use + +- **Implementing designs**: Fetch layout, colors, typography, spacing directly from Figma +- **Extracting assets**: Download PNGs/SVGs referenced in designs +- **Design-to-code**: Get structured data about components and styles +- **Auditing designs**: Programmatically analyze design systems + +## URL Formats Supported + +``` +https://www.figma.com/file/{fileKey}/{title} +https://www.figma.com/file/{fileKey}/{title}?node-id={nodeId} +https://www.figma.com/design/{fileKey}/{title} +``` + +## Output Format + +The simplified output transforms verbose Figma API responses into clean structures: + +```typescript +{ + name: "My Design", + lastModified: "2024-01-15T10:30:00Z", + thumbnailUrl: "...", + nodes: [{ + id: "123:456", + name: "Button Primary", + type: "FRAME", + text: "Click me", + textStyle: "style_001", + fills: "fill_001", + strokes: "stroke_001", + effects: "effect_001", + layout: "layout_001", + borderRadius: "8px", + opacity: 0.9, + children: [...] + }], + components: { + "component-123": { name: "Button", description: "..." } + }, + globalVars: { + styles: { + "style_001": { fontFamily: "Inter", fontSize: 16, ... }, + "fill_001": [{ type: "SOLID", hex: "#FF5733" }], + "layout_001": { mode: "row", gap: "8px", justifyContent: "center" } + } + } +} +``` + +## Efficiency Guide + +### Use --depth to Limit Data + +Figma files can be huge. Use `--depth` to fetch only what you need: + +```bash +{figmaDir}/figma-tools get --depth 1 +{figmaDir}/figma-tools get --depth 2 +``` + +### Get Specific Nodes + +```bash +{figmaDir}/figma-tools get "https://www.figma.com/file/ABC/My?node-id=123%3A456" +``` + +### Batch Asset Downloads + +```bash +{figmaDir}/figma-tools download ./assets --scale 2 +``` diff --git a/pi/files/agent/skills/pi-skills/figma-tools/figma-download.js b/pi/files/agent/skills/pi-skills/figma-tools/figma-download.js new file mode 100644 index 0000000..b302d1d --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/figma-download.js @@ -0,0 +1,4 @@ +#!/usr/bin/env -S node --experimental-strip-types +// Prepend 'download' to args and run CLI +process.argv = [process.argv[0], process.argv[1], 'download', ...process.argv.slice(2)]; +import('./src/cli.ts'); diff --git a/pi/files/agent/skills/pi-skills/figma-tools/figma-get.js b/pi/files/agent/skills/pi-skills/figma-tools/figma-get.js new file mode 100755 index 0000000..a44d580 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/figma-get.js @@ -0,0 +1,4 @@ +#!/usr/bin/env -S node --experimental-strip-types +// Prepend 'get' to args and run CLI +process.argv = [process.argv[0], process.argv[1], 'get', ...process.argv.slice(2)]; +import('./src/cli.ts'); diff --git a/pi/files/agent/skills/pi-skills/figma-tools/figma-parse.js b/pi/files/agent/skills/pi-skills/figma-tools/figma-parse.js new file mode 100644 index 0000000..8fa3995 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/figma-parse.js @@ -0,0 +1,4 @@ +#!/usr/bin/env -S node --experimental-strip-types +// Prepend 'parse' to args and run CLI +process.argv = [process.argv[0], process.argv[1], 'parse', ...process.argv.slice(2)]; +import('./src/cli.ts'); diff --git a/pi/files/agent/skills/pi-skills/figma-tools/package.json b/pi/files/agent/skills/pi-skills/figma-tools/package.json new file mode 100644 index 0000000..4bd5cf8 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/package.json @@ -0,0 +1,21 @@ +{ + "name": "figma-tools", + "version": "1.0.0", + "type": "module", + "description": "Direct Figma API tools for fetching and parsing design files", + "author": "Pi", + "license": "MIT", + "packageManager": "pnpm@10.0.0", + "scripts": { + "build": "tsc", + "dev": "tsc --watch" + }, + "dependencies": { + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + } +} diff --git a/pi/files/agent/skills/pi-skills/figma-tools/pnpm-lock.yaml b/pi/files/agent/skills/pi-skills/figma-tools/pnpm-lock.yaml new file mode 100644 index 0000000..6bdd10c --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/pnpm-lock.yaml @@ -0,0 +1,64 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + +packages: + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@types/js-yaml@4.0.9': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + argparse@2.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} diff --git a/pi/files/agent/skills/pi-skills/figma-tools/src/cli.ts b/pi/files/agent/skills/pi-skills/figma-tools/src/cli.ts new file mode 100644 index 0000000..df6dfd2 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/src/cli.ts @@ -0,0 +1,311 @@ +#!/usr/bin/env node + +/** + * Figma Tools CLI + */ + +import { FigmaClient, parseFigmaUrl, downloadImage } from './figma-client.ts'; +import { simplifyDesign } from './simplify.ts'; +import type { FigmaNode, FigmaFileResponse, FigmaNodeResponse } from './types.ts'; + +const API_KEY = process.env.FIGMA_API_KEY; + +function showHelp(): void { + console.log(` +Figma Tools - Fetch and parse Figma design files + +Usage: + figma-tools get [options] Fetch and simplify Figma file/node + figma-tools download Download images/assets + figma-tools parse [options] Parse local JSON file + +Commands: + + get + Fetch a Figma file or specific node. Outputs simplified JSON. + + Options: + --depth N Limit tree depth (1-4) + --raw Output raw API response + --yaml Output as YAML + + Examples: + figma-tools get https://www.figma.com/file/ABC123/My-Design + figma-tools get "https://www.figma.com/file/ABC123/My-Design?node-id=123%3A456" --depth 2 + figma-tools get https://www.figma.com/file/ABC123/My-Design --raw > raw.json + + download + Download images and assets from a Figma file. + + Options: + --scale N PNG scale factor (1-4, default: 1) + --format fmt Image format: png, svg, or both (default: png) + + Examples: + figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets + figma-tools download https://www.figma.com/file/ABC123/My-Design ./assets --scale 2 + + parse + Simplify a raw Figma API JSON file. + + Options: + --yaml Output as YAML + + Examples: + figma-tools parse ./raw-figma-response.json + figma-tools parse ./raw.json --yaml > design.yaml + +Setup: + export FIGMA_API_KEY="your-token-here" + Get a token from Figma Settings → Personal Access Tokens +`); +} + +async function output(data: unknown, useYaml: boolean): Promise { + if (useYaml) { + const yaml = await import('js-yaml'); + console.log(yaml.default.dump(data, { indent: 2, lineWidth: 120 })); + } else { + console.log(JSON.stringify(data, null, 2)); + } +} + +async function getCommand(args: string[]): Promise { + if (!API_KEY) { + console.error('Error: FIGMA_API_KEY environment variable required'); + console.error('Get a token from: https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens'); + process.exit(1); + } + + const url = args.find((a) => !a.startsWith('--')); + if (!url) { + console.error('Error: No URL provided'); + console.error('Usage: figma-tools get [--depth N] [--raw] [--yaml]'); + process.exit(1); + } + + const depthArg = args.find((a, i) => a === '--depth' && args[i + 1]); + const depth = depthArg ? parseInt(args[args.indexOf(depthArg) + 1]) : null; + const raw = args.includes('--raw'); + const yaml = args.includes('--yaml'); + + const parsed = parseFigmaUrl(url); + if (!parsed) { + console.error('Error: Invalid Figma URL'); + console.error('Expected: https://www.figma.com/file/{fileKey}/{title} or with ?node-id='); + process.exit(1); + } + + const client = new FigmaClient(API_KEY); + + let data: FigmaFileResponse | FigmaNodeResponse; + if (parsed.nodeId) { + console.error(`Fetching node ${parsed.nodeId} from file ${parsed.fileKey}...`); + data = await client.getNode(parsed.fileKey, parsed.nodeId, depth); + } else { + console.error(`Fetching file ${parsed.fileKey}...`); + data = await client.getFile(parsed.fileKey, depth); + } + + if (raw) { + await output(data, yaml); + } else { + console.error('Simplifying response...'); + const simplified = simplifyDesign(data); + await output(simplified, yaml); + } +} + +async function downloadCommand(args: string[]): Promise { + if (!API_KEY) { + console.error('Error: FIGMA_API_KEY environment variable required'); + process.exit(1); + } + + const url = args.find((a) => !a.startsWith('--')); + const outputDir = args.filter((a) => !a.startsWith('--'))[1]; + + if (!url || !outputDir) { + console.error('Error: URL and output directory required'); + console.error('Usage: figma-tools download [--scale N] [--format png|svg]'); + process.exit(1); + } + + const scaleArg = args.find((a, i) => a === '--scale' && args[i + 1]); + const scale = scaleArg ? parseInt(args[args.indexOf(scaleArg) + 1]) : 1; + const formatArg = args.find((a, i) => a === '--format' && args[i + 1]); + const format = formatArg ? args[args.indexOf(formatArg) + 1] : 'png'; + + const parsed = parseFigmaUrl(url); + if (!parsed) { + console.error('Error: Invalid Figma URL'); + process.exit(1); + } + + const client = new FigmaClient(API_KEY); + + console.error('Fetching file structure...'); + const fileData = await client.getFile(parsed.fileKey, 3); + + const imageNodes: Array<{ id: string; name: string; imageRef?: string }> = []; + const svgNodes: Array<{ id: string; name: string }> = []; + + function sanitizeFilename(name: string): string { + return name + .replace(/[^a-zA-Z0-9\-_./\s]/g, '') + .replace(/\s+/g, '_') + .substring(0, 100); + } + + function collectNodes(nodes: FigmaNode[], parentPath = ''): void { + for (const node of nodes) { + const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name; + + if (node.fills) { + const fills = Array.isArray(node.fills) ? node.fills : [node.fills]; + for (const fill of fills) { + if (fill?.type === 'IMAGE' && fill.imageRef) { + imageNodes.push({ + id: node.id, + name: sanitizeFilename(currentPath) + '.png', + imageRef: fill.imageRef, + }); + } + } + } + + if ( + ['VECTOR', 'BOOLEAN_OPERATION', 'STAR', 'POLYGON', 'LINE', 'ELLIPSE', 'RECTANGLE'].includes( + node.type + ) + ) { + svgNodes.push({ + id: node.id, + name: sanitizeFilename(currentPath) + '.svg', + }); + } + + if (node.children) { + collectNodes(node.children, currentPath); + } + } + } + + let nodesToProcess: FigmaNode[]; + if (parsed.nodeId) { + // fileData is FigmaNodeResponse when we requested a specific node + const nodeResponse = fileData as unknown as { nodes?: Record }; + nodesToProcess = nodeResponse.nodes?.[parsed.nodeId]?.document?.children || []; + } else { + nodesToProcess = fileData.document?.children || []; + } + + collectNodes(nodesToProcess); + + console.error(`Found ${imageNodes.length} image fills, ${svgNodes.length} exportable nodes`); + + const results: string[] = []; + + if ((format === 'png' || format === 'both') && (imageNodes.length > 0 || svgNodes.length > 0)) { + const pngIds = [ + ...imageNodes.map((n) => n.id), + ...svgNodes.map((n) => n.id), + ]; + + console.error(`Downloading ${pngIds.length} PNGs at ${scale}x...`); + const images = await client.getImages(parsed.fileKey, pngIds, 'png', scale); + + for (const [nodeId, imageUrl] of Object.entries(images)) { + if (imageUrl) { + const node = [...imageNodes, ...svgNodes].find((n) => n.id === nodeId); + if (node) { + const fileName = node.name.endsWith('.png') ? node.name : `${node.name}.png`; + const path = await downloadImage(imageUrl, outputDir, fileName); + results.push(path); + } + } + } + } + + if ((format === 'svg' || format === 'both') && svgNodes.length > 0) { + console.error(`Downloading ${svgNodes.length} SVGs...`); + const images = await client.getImages( + parsed.fileKey, + svgNodes.map((n) => n.id), + 'svg' + ); + + for (const [nodeId, imageUrl] of Object.entries(images)) { + if (imageUrl) { + const node = svgNodes.find((n) => n.id === nodeId); + if (node) { + const path = await downloadImage(imageUrl, outputDir, node.name); + results.push(path); + } + } + } + } + + console.error(`\nDownloaded ${results.length} files to: ${outputDir}`); + for (const path of results) { + console.log(path); + } +} + +async function parseCommand(args: string[]): Promise { + const fs = await import('fs'); + + const filePath = args.find((a) => !a.startsWith('--')); + if (!filePath) { + console.error('Error: No file path provided'); + console.error('Usage: figma-tools parse [--yaml]'); + process.exit(1); + } + + const yaml = args.includes('--yaml'); + + if (!fs.existsSync(filePath)) { + console.error(`Error: File not found: ${filePath}`); + process.exit(1); + } + + const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + console.error('Simplifying...'); + const simplified = simplifyDesign(raw); + await output(simplified, yaml); +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || command === '--help' || command === '-h') { + showHelp(); + process.exit(0); + } + + const commandArgs = args.slice(1); + + try { + switch (command) { + case 'get': + await getCommand(commandArgs); + break; + case 'download': + await downloadCommand(commandArgs); + break; + case 'parse': + await parseCommand(commandArgs); + break; + default: + console.error(`Unknown command: ${command}`); + showHelp(); + process.exit(1); + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } +} + +main(); diff --git a/pi/files/agent/skills/pi-skills/figma-tools/src/figma-client.ts b/pi/files/agent/skills/pi-skills/figma-tools/src/figma-client.ts new file mode 100644 index 0000000..d945e27 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/src/figma-client.ts @@ -0,0 +1,114 @@ +/** + * Figma API Client + */ + +import type { + FigmaFileResponse, + FigmaNodeResponse, + FigmaImageResponse, + FigmaImageFillsResponse, + ParsedFigmaUrl, +} from './types.ts'; + +const BASE_URL = 'https://api.figma.com/v1'; + +export class FigmaClient { + private apiKey: string; + + constructor(apiKey: string) { + this.apiKey = apiKey; + } + + private async request(endpoint: string): Promise { + const url = `${BASE_URL}${endpoint}`; + const response = await fetch(url, { + headers: { + 'X-Figma-Token': this.apiKey, + }, + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error'); + throw new Error(`Figma API error (${response.status}): ${text}`); + } + + return response.json() as Promise; + } + + async getFile(fileKey: string, depth: number | null = null): Promise { + const depthParam = depth ? `?depth=${depth}` : ''; + return this.request(`/files/${fileKey}${depthParam}`); + } + + async getNode(fileKey: string, nodeId: string, depth: number | null = null): Promise { + const encodedNodeId = encodeURIComponent(nodeId); + const depthParam = depth ? `&depth=${depth}` : ''; + return this.request(`/files/${fileKey}/nodes?ids=${encodedNodeId}${depthParam}`); + } + + async getImageFills(fileKey: string): Promise { + return this.request(`/files/${fileKey}/images`); + } + + async getImages( + fileKey: string, + nodeIds: string[], + format: 'png' | 'svg' = 'png', + scale = 1 + ): Promise> { + if (nodeIds.length === 0) return {}; + + const ids = nodeIds.join(','); + let endpoint: string; + + if (format === 'svg') { + const params = `ids=${ids}&format=svg&svg_outline_text=true&svg_include_id=false&svg_simplify_stroke=true`; + endpoint = `/images/${fileKey}?${params}`; + } else { + endpoint = `/images/${fileKey}?ids=${ids}&format=png&scale=${scale}`; + } + + const response = await this.request(endpoint); + return response.images; + } +} + +export function parseFigmaUrl(url: string): ParsedFigmaUrl | null { + const patterns = [ + /figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)(?:\/[^?]+)?(?:\?.*node-id=([0-9-]+))?/, + /figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/, + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match) { + return { + fileKey: match[1], + nodeId: match[2] ? match[2].replace('-', ':') : null, + }; + } + } + + return null; +} + +export async function downloadImage(imageUrl: string, localPath: string, fileName: string): Promise { + const fs = await import('fs'); + const path = await import('path'); + + if (!fs.existsSync(localPath)) { + fs.mkdirSync(localPath, { recursive: true }); + } + + const fullPath = path.join(localPath, fileName); + + const response = await fetch(imageUrl, { method: 'GET' }); + if (!response.ok) { + throw new Error(`Failed to download image: ${response.statusText}`); + } + + const buffer = await response.arrayBuffer(); + fs.writeFileSync(fullPath, Buffer.from(buffer)); + + return fullPath; +} diff --git a/pi/files/agent/skills/pi-skills/figma-tools/src/simplify.ts b/pi/files/agent/skills/pi-skills/figma-tools/src/simplify.ts new file mode 100644 index 0000000..17647a8 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/src/simplify.ts @@ -0,0 +1,472 @@ +/** + * Figma Response Simplifier + */ + +import type { + FigmaNode, + FigmaFileResponse, + FigmaNodeResponse, + FigmaColor, + FigmaPaint, + FigmaEffect, + FigmaTextStyle, + SimplifiedDesign, + SimplifiedNode, + SimplifiedLayout, + SimplifiedStroke, + SimplifiedEffects, + SimplifiedTextStyle, + SimplifiedFill, +} from './types.ts'; + +interface GlobalVars { + styles: Record; +} + +let varCounter = 0; + +function generateVarId(prefix: string): string { + return `${prefix}_${String(++varCounter).padStart(3, '0')}`; +} + +function findOrCreateVar(globalVars: GlobalVars, value: unknown, prefix: string): string { + const existing = Object.entries(globalVars.styles).find( + ([, v]) => JSON.stringify(v) === JSON.stringify(value) + ); + + if (existing) { + return existing[0]; + } + + const id = generateVarId(prefix); + globalVars.styles[id] = value; + return id; +} + +function isVisible(item: { visible?: boolean }): boolean { + return item.visible !== false; +} + +function round(num: number): number { + return Math.round(num * 100) / 100; +} + +function convertColor(rgba: FigmaColor, opacity = 1): { hex: string; opacity: number } { + const r = Math.round(rgba.r * 255); + const g = Math.round(rgba.g * 255); + const b = Math.round(rgba.b * 255); + const a = round(rgba.a * opacity); + + const hex = + '#' + + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); + + return { hex, opacity: a }; +} + +function formatRgba(rgba: FigmaColor, opacity = 1): string { + const r = Math.round(rgba.r * 255); + const g = Math.round(rgba.g * 255); + const b = Math.round(rgba.b * 255); + const a = round(rgba.a * opacity); + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +function formatCSSShorthand(values: { + top: number; + right: number; + bottom: number; + left: number; +}): string { + const { top, right, bottom, left } = values; + if (top === right && right === bottom && bottom === left) { + return `${top}px`; + } + if (top === bottom && right === left) { + return `${top}px ${right}px`; + } + return `${top}px ${right}px ${bottom}px ${left}px`; +} + +function simplifyFill(fill: FigmaPaint): SimplifiedFill { + if (fill.type === 'IMAGE') { + return { + type: 'IMAGE', + imageRef: fill.imageRef, + scaleMode: fill.scaleMode, + }; + } + + if (fill.type === 'SOLID' && fill.color) { + const { hex, opacity } = convertColor(fill.color, fill.opacity); + return opacity === 1 ? hex : formatRgba(fill.color, opacity); + } + + if (fill.type?.startsWith('GRADIENT_')) { + return { + type: fill.type, + gradientHandlePositions: fill.gradientHandlePositions, + gradientStops: fill.gradientStops?.map((stop) => ({ + position: stop.position, + color: convertColor(stop.color), + })), + }; + } + + return { type: fill.type }; +} + +function simplifyTextStyle(style: FigmaTextStyle): SimplifiedTextStyle { + const simplified: SimplifiedTextStyle = {}; + + if (style.fontFamily) simplified.fontFamily = style.fontFamily; + if (style.fontWeight) simplified.fontWeight = style.fontWeight; + if (style.fontSize) simplified.fontSize = style.fontSize; + + if (style.lineHeightPx && style.fontSize) { + simplified.lineHeight = `${(style.lineHeightPx / style.fontSize).toFixed(2)}em`; + } + + if (style.letterSpacing && style.letterSpacing !== 0 && style.fontSize) { + simplified.letterSpacing = `${((style.letterSpacing / style.fontSize) * 100).toFixed(1)}%`; + } + + if (style.textCase) simplified.textCase = style.textCase; + if (style.textAlignHorizontal) simplified.textAlignHorizontal = style.textAlignHorizontal; + if (style.textAlignVertical) simplified.textAlignVertical = style.textAlignVertical; + + return simplified; +} + +function simplifyStrokes(node: FigmaNode): SimplifiedStroke { + const result: SimplifiedStroke = { colors: [] }; + + if (node.strokes?.length) { + result.colors = node.strokes.filter(isVisible).map(simplifyFill); + } + + if (typeof node.strokeWeight === 'number' && node.strokeWeight > 0) { + result.strokeWeight = `${node.strokeWeight}px`; + } + + if (node.individualStrokeWeights) { + const { top, right, bottom, left } = node.individualStrokeWeights; + result.strokeWeight = formatCSSShorthand({ top, right, bottom, left }); + } + + if (node.strokeDashes?.length) { + result.strokeDashes = node.strokeDashes; + } + + return result; +} + +function simplifyEffects(node: FigmaNode): SimplifiedEffects { + if (!node.effects?.length) return {}; + + const visible = node.effects.filter((e) => e.visible); + const result: SimplifiedEffects = {}; + + const dropShadows = visible + .filter((e): e is FigmaEffect & { type: 'DROP_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'DROP_SHADOW') + .map((e) => `${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`); + + const innerShadows = visible + .filter((e): e is FigmaEffect & { type: 'INNER_SHADOW'; offset: { x: number; y: number }; color: FigmaColor } => e.type === 'INNER_SHADOW') + .map((e) => `inset ${e.offset.x}px ${e.offset.y}px ${e.radius}px ${e.spread ?? 0}px ${formatRgba(e.color)}`); + + const boxShadow = [...dropShadows, ...innerShadows].join(', '); + + const layerBlurs = visible + .filter((e): e is FigmaEffect & { type: 'LAYER_BLUR' } => e.type === 'LAYER_BLUR') + .map((e) => `blur(${e.radius}px)`); + + const bgBlurs = visible + .filter((e): e is FigmaEffect & { type: 'BACKGROUND_BLUR' } => e.type === 'BACKGROUND_BLUR') + .map((e) => `blur(${e.radius}px)`); + + if (boxShadow) { + if (node.type === 'TEXT') { + result.textShadow = boxShadow; + } else { + result.boxShadow = boxShadow; + } + } + + if (layerBlurs.length) result.filter = layerBlurs.join(' '); + if (bgBlurs.length) result.backdropFilter = bgBlurs.join(' '); + + return result; +} + +function convertAlign(align?: string): string | undefined { + const map: Record = { + MIN: 'flex-start', + MAX: 'flex-end', + CENTER: 'center', + SPACE_BETWEEN: 'space-between', + BASELINE: 'baseline', + }; + return map[align || '']; +} + +function convertSelfAlign(align?: string): string | undefined { + const map: Record = { + MIN: 'flex-start', + MAX: 'flex-end', + CENTER: 'center', + STRETCH: 'stretch', + }; + return map[align || '']; +} + +function convertSizing(sizing?: string): 'fixed' | 'fill' | 'hug' | undefined { + const map: Record = { + FIXED: 'fixed', + FILL: 'fill', + HUG: 'hug', + }; + return map[sizing || '']; +} + +function simplifyLayout(node: FigmaNode, parent?: FigmaNode): SimplifiedLayout { + const layout: SimplifiedLayout = { mode: 'none' }; + + if (node.layoutMode && node.layoutMode !== 'NONE') { + layout.mode = node.layoutMode === 'HORIZONTAL' ? 'row' : 'column'; + + layout.justifyContent = convertAlign(node.primaryAxisAlignItems); + layout.alignItems = convertAlign(node.counterAxisAlignItems); + layout.alignSelf = convertSelfAlign(node.layoutAlign); + + if (node.layoutWrap === 'WRAP') { + layout.wrap = true; + } + + if (node.itemSpacing) { + layout.gap = `${node.itemSpacing}px`; + } + + if (node.paddingTop || node.paddingRight || node.paddingBottom || node.paddingLeft) { + layout.padding = formatCSSShorthand({ + top: node.paddingTop || 0, + right: node.paddingRight || 0, + bottom: node.paddingBottom || 0, + left: node.paddingLeft || 0, + }); + } + + const overflow: ('x' | 'y')[] = []; + if (node.overflowDirection?.includes('HORIZONTAL')) overflow.push('x'); + if (node.overflowDirection?.includes('VERTICAL')) overflow.push('y'); + if (overflow.length) layout.overflowScroll = overflow; + } + + if (node.layoutSizingHorizontal || node.layoutSizingVertical) { + layout.sizing = {}; + if (node.layoutSizingHorizontal) { + layout.sizing.horizontal = convertSizing(node.layoutSizingHorizontal); + } + if (node.layoutSizingVertical) { + layout.sizing.vertical = convertSizing(node.layoutSizingVertical); + } + } + + const parentHasLayout = parent?.layoutMode && parent.layoutMode !== 'NONE'; + if (node.layoutPositioning === 'ABSOLUTE') { + layout.position = 'absolute'; + } + + if (node.absoluteBoundingBox && parent?.absoluteBoundingBox) { + const isAbsolute = node.layoutPositioning === 'ABSOLUTE'; + const isOutsideAutoLayout = parentHasLayout && node.layoutPositioning === 'ABSOLUTE'; + + if (isAbsolute || isOutsideAutoLayout) { + layout.locationRelativeToParent = { + x: round(node.absoluteBoundingBox.x - parent.absoluteBoundingBox.x), + y: round(node.absoluteBoundingBox.y - parent.absoluteBoundingBox.y), + }; + } + } + + if (node.absoluteBoundingBox) { + const dims: { width?: number; height?: number; aspectRatio?: number } = {}; + const isRow = layout.mode === 'row'; + const isCol = layout.mode === 'column'; + + const includeWidth = !isRow || node.layoutSizingHorizontal === 'FIXED'; + const includeHeight = !isCol || node.layoutSizingVertical === 'FIXED'; + + if (includeWidth && node.absoluteBoundingBox.width) { + dims.width = round(node.absoluteBoundingBox.width); + } + if (includeHeight && node.absoluteBoundingBox.height) { + dims.height = round(node.absoluteBoundingBox.height); + } + + if (node.preserveRatio && dims.width && dims.height) { + dims.aspectRatio = round(dims.width / dims.height); + } + + if (Object.keys(dims).length) { + layout.dimensions = dims; + } + } + + return layout; +} + +function simplifyNode(node: FigmaNode, globalVars: GlobalVars, parent?: FigmaNode): SimplifiedNode | null { + if (!node) return null; + + const simplified: SimplifiedNode = { + id: node.id, + name: node.name, + type: node.type, + }; + + if (node.type === 'INSTANCE') { + if (node.componentId) { + simplified.componentId = node.componentId; + } + if (node.componentProperties) { + simplified.componentProperties = Object.entries(node.componentProperties).map(([name, prop]) => ({ + name, + value: String(prop.value), + type: prop.type, + })); + } + } + + if (node.characters) { + simplified.text = node.characters; + } + + if (node.style && Object.keys(node.style).length > 0) { + simplified.textStyle = findOrCreateVar(globalVars, simplifyTextStyle(node.style), 'style'); + } + + if (node.fills?.length) { + const fills = node.fills.filter(isVisible).map(simplifyFill); + if (fills.length) { + simplified.fills = findOrCreateVar(globalVars, fills, 'fill'); + } + } + + const strokes = simplifyStrokes(node); + if (strokes.colors?.length) { + simplified.strokes = findOrCreateVar(globalVars, strokes, 'stroke'); + } + + const effects = simplifyEffects(node); + if (Object.keys(effects).length) { + simplified.effects = findOrCreateVar(globalVars, effects, 'effect'); + } + + if (typeof node.opacity === 'number' && node.opacity !== 1) { + simplified.opacity = node.opacity; + } + + if (typeof node.cornerRadius === 'number') { + simplified.borderRadius = `${node.cornerRadius}px`; + } else if (node.rectangleCornerRadii?.length === 4) { + const [tl, tr, br, bl] = node.rectangleCornerRadii; + if (tl === tr && tr === br && br === bl) { + simplified.borderRadius = `${tl}px`; + } else { + simplified.borderRadius = `${tl}px ${tr}px ${br}px ${bl}px`; + } + } + + const layout = simplifyLayout(node, parent); + if (Object.keys(layout).length > 1 || layout.mode !== 'none') { + simplified.layout = findOrCreateVar(globalVars, layout, 'layout'); + } + + if (node.children?.length) { + const children = node.children + .filter(isVisible) + .map((child) => simplifyNode(child, globalVars, node)) + .filter((child): child is SimplifiedNode => child !== null); + + if (children.length) { + simplified.children = children; + } + } + + if (node.type === 'VECTOR') { + simplified.type = 'IMAGE-SVG'; + } + + return simplified; +} + +export function simplifyDesign(data: FigmaFileResponse | FigmaNodeResponse): SimplifiedDesign { + const isNodeResponse = 'nodes' in data; + + let nodesToProcess: FigmaNode[]; + let components: Record = {}; + let componentSets: Record = {}; + + if (isNodeResponse) { + const nodeResponses = Object.values(data.nodes); + nodesToProcess = nodeResponses.map((n) => n.document); + + for (const nodeResponse of nodeResponses) { + if (nodeResponse.components) { + Object.assign(components, nodeResponse.components); + } + if (nodeResponse.componentSets) { + Object.assign(componentSets, nodeResponse.componentSets); + } + } + } else { + nodesToProcess = data.document?.children || []; + components = data.components || {}; + componentSets = data.componentSets || {}; + } + + const globalVars: GlobalVars = { styles: {} }; + + const simplifiedNodes = nodesToProcess + .filter(isVisible) + .map((node) => simplifyNode(node, globalVars)) + .filter((node): node is SimplifiedNode => node !== null); + + return removeEmptyKeys({ + name: data.name, + lastModified: data.lastModified, + thumbnailUrl: data.thumbnailUrl || '', + nodes: simplifiedNodes, + components, + componentSets, + globalVars, + }); +} + +export function removeEmptyKeys(obj: T): T { + if (typeof obj !== 'object' || obj === null) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(removeEmptyKeys).filter((v) => v !== undefined) as unknown as T; + } + + const result = {} as Record; + for (const [key, value] of Object.entries(obj as Record)) { + const cleaned = removeEmptyKeys(value); + const isEmpty = + cleaned === undefined || + (Array.isArray(cleaned) && cleaned.length === 0) || + (typeof cleaned === 'object' && + cleaned !== null && + Object.keys(cleaned).length === 0); + + if (!isEmpty) { + result[key] = cleaned; + } + } + + return result as T; +} diff --git a/pi/files/agent/skills/pi-skills/figma-tools/src/types.ts b/pi/files/agent/skills/pi-skills/figma-tools/src/types.ts new file mode 100644 index 0000000..ff09e81 --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/src/types.ts @@ -0,0 +1,214 @@ +/** + * Figma API Types + */ + +export interface FigmaColor { + r: number; + g: number; + b: number; + a: number; +} + +export interface FigmaPaint { + type: 'SOLID' | 'IMAGE' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'GRADIENT_ANGULAR' | 'GRADIENT_DIAMOND'; + color?: FigmaColor; + opacity?: number; + imageRef?: string; + scaleMode?: 'FILL' | 'FIT' | 'STRETCH' | 'TILE'; + gradientHandlePositions?: Array<{ x: number; y: number }>; + gradientStops?: Array<{ position: number; color: FigmaColor }>; + visible?: boolean; +} + +export interface FigmaEffect { + type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR'; + visible: boolean; + offset?: { x: number; y: number }; + radius: number; + spread?: number; + color?: FigmaColor; +} + +export interface FigmaTextStyle { + fontFamily?: string; + fontWeight?: number; + fontSize?: number; + lineHeightPx?: number; + letterSpacing?: number; + textCase?: string; + textAlignHorizontal?: string; + textAlignVertical?: string; +} + +export interface FigmaNode { + id: string; + name: string; + type: string; + visible?: boolean; + children?: FigmaNode[]; + characters?: string; + style?: FigmaTextStyle; + fills?: FigmaPaint[]; + strokes?: FigmaPaint[]; + strokeWeight?: number; + individualStrokeWeights?: { top: number; right: number; bottom: number; left: number }; + strokeDashes?: number[]; + effects?: FigmaEffect[]; + opacity?: number; + cornerRadius?: number; + rectangleCornerRadii?: number[]; + layoutMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'; + primaryAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN' | 'BASELINE'; + counterAxisAlignItems?: 'MIN' | 'MAX' | 'CENTER' | 'SPACE_BETWEEN' | 'BASELINE'; + layoutAlign?: 'MIN' | 'MAX' | 'CENTER' | 'STRETCH'; + layoutPositioning?: 'AUTO' | 'ABSOLUTE'; + layoutWrap?: 'NO_WRAP' | 'WRAP'; + itemSpacing?: number; + paddingTop?: number; + paddingRight?: number; + paddingBottom?: number; + paddingLeft?: number; + overflowDirection?: string; + layoutSizingHorizontal?: 'FIXED' | 'FILL' | 'HUG'; + layoutSizingVertical?: 'FIXED' | 'FILL' | 'HUG'; + layoutGrow?: number; + preserveRatio?: boolean; + absoluteBoundingBox?: { + x: number; + y: number; + width: number; + height: number; + }; + clipsContent?: boolean; + componentId?: string; + componentProperties?: Record; +} + +export interface FigmaComponent { + name: string; + description?: string; + type?: string; +} + +export interface FigmaComponentSet { + name: string; + description?: string; +} + +export interface FigmaFileResponse { + name: string; + lastModified: string; + thumbnailUrl?: string; + document: FigmaNode; + components?: Record; + componentSets?: Record; +} + +export interface FigmaNodeResponse { + name: string; + lastModified: string; + thumbnailUrl?: string; + nodes: Record; + componentSets?: Record; + }>; +} + +export interface FigmaImageResponse { + images: Record; +} + +export interface FigmaImageFillsResponse { + meta: { + images: Record; + }; +} + +export type FigmaResponse = FigmaFileResponse | FigmaNodeResponse; + +export interface SimplifiedLayout { + mode: 'none' | 'row' | 'column'; + justifyContent?: string; + alignItems?: string; + alignSelf?: string; + wrap?: boolean; + gap?: string; + padding?: string; + position?: 'absolute'; + locationRelativeToParent?: { x: number; y: number }; + dimensions?: { width?: number; height?: number; aspectRatio?: number }; + sizing?: { horizontal?: 'fixed' | 'fill' | 'hug'; vertical?: 'fixed' | 'fill' | 'hug' }; + overflowScroll?: ('x' | 'y')[]; +} + +export interface SimplifiedStroke { + colors: SimplifiedFill[]; + strokeWeight?: string; + strokeDashes?: number[]; +} + +export interface SimplifiedEffects { + boxShadow?: string; + textShadow?: string; + filter?: string; + backdropFilter?: string; +} + +export interface SimplifiedTextStyle { + fontFamily?: string; + fontWeight?: number; + fontSize?: number; + lineHeight?: string; + letterSpacing?: string; + textCase?: string; + textAlignHorizontal?: string; + textAlignVertical?: string; +} + +export type SimplifiedFill = + | { + type?: string; + hex?: string; + rgba?: string; + opacity?: number; + imageRef?: string; + scaleMode?: string; + gradientHandlePositions?: Array<{ x: number; y: number }>; + gradientStops?: Array<{ position: number; color: { hex: string; opacity: number } }>; + } + | string; + +export interface SimplifiedNode { + id: string; + name: string; + type: string; + text?: string; + textStyle?: string; + fills?: string; + strokes?: string; + effects?: string; + layout?: string; + opacity?: number; + borderRadius?: string; + componentId?: string; + componentProperties?: Array<{ name: string; value: string; type: string }>; + children?: SimplifiedNode[]; +} + +export interface SimplifiedDesign { + name: string; + lastModified: string; + thumbnailUrl: string; + nodes: SimplifiedNode[]; + components: Record; + componentSets: Record; + globalVars: { + styles: Record; + }; +} + +export interface ParsedFigmaUrl { + fileKey: string; + nodeId: string | null; +} diff --git a/pi/files/agent/skills/pi-skills/figma-tools/tsconfig.json b/pi/files/agent/skills/pi-skills/figma-tools/tsconfig.json new file mode 100644 index 0000000..b4c762b --- /dev/null +++ b/pi/files/agent/skills/pi-skills/figma-tools/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}