# Directory Structure ``` ├── .env.example ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.en.md ├── README.md ├── smithery.yaml ├── src │ ├── config.ts │ ├── index.ts │ ├── server.ts │ ├── services │ │ ├── figma.ts │ │ └── simplify-node-response.ts │ ├── transformers │ │ ├── effects.ts │ │ ├── layout-optimizer.ts │ │ ├── layout.ts │ │ ├── node.ts │ │ └── style.ts │ └── utils │ ├── common.ts │ ├── file.ts │ ├── identity.ts │ ├── spatial-projection.ts │ └── svg.ts ├── test │ ├── run-simplify-test.sh │ ├── test-output │ │ ├── real-node-data.json │ │ ├── simplified-node-data.json │ │ └── viewer.html │ └── test-simplify.ts ├── tsconfig.json └── tsup.config.ts ``` # Files -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- ``` 1 | v20 ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "semi": true, 3 | "trailingComma": "all", 4 | "singleQuote": false, 5 | "printWidth": 100, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } 9 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | 5 | # Build output 6 | dist 7 | 8 | # Environment variables 9 | .env 10 | .env.local 11 | .env.*.local 12 | 13 | # IDE 14 | .vscode/* 15 | !.vscode/extensions.json 16 | !.vscode/settings.json 17 | .idea 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | # Logs 25 | logs 26 | *.log 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | pnpm-debug.log* 31 | 32 | # Testing 33 | coverage 34 | 35 | # OS 36 | .DS_Store 37 | Thumbs.db ``` -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "plugins": ["@typescript-eslint"], 9 | "parserOptions": { 10 | "ecmaVersion": 2022, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/explicit-function-return-type": "warn", 15 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 16 | "@typescript-eslint/no-explicit-any": "warn" 17 | } 18 | } ``` -------------------------------------------------------------------------------- /README.en.md: -------------------------------------------------------------------------------- ```markdown 1 | # Figma MCP Server 2 | 3 | > This project is an improved version of the open-source [Figma-Context-MCP](https://github.com/GLips/Figma-Context-MCP), with optimized data structures and conversion logic. 4 | 5 | English | [中文版](./README.md) 6 | 7 | This is a server based on the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) that enables seamless integration of Figma design files with AI coding tools like [Cursor](https://cursor.sh/), [Windsurf](https://codeium.com/windsurf), [Cline](https://cline.bot/), and more. 8 | 9 | When AI tools can access Figma design data, they can generate code that accurately matches designs in a single pass, performing much better than traditional methods like screenshots. 10 | 11 | ## Features 12 | 13 | - Convert Figma design data into AI model-friendly formats 14 | - Support retrieving layout and style information for Figma files, artboards, or components 15 | - Support downloading images and icon resources from Figma 16 | - Reduce the context provided to models, improving AI response accuracy and relevance 17 | 18 | ## Key Differences from Original Version 19 | 20 | ### Design Data Return Format 21 | 22 | ```json 23 | { 24 | // Design file basic information 25 | "name": "Design file name", 26 | "lastModified": "Last modification time", 27 | "thumbnailUrl": "Thumbnail URL", 28 | 29 | // Node array containing all page elements 30 | "nodes": [ 31 | { 32 | // Node basic information 33 | "id": "Node ID, e.g. 1:156", 34 | "name": "Node name", 35 | "type": "Node type, such as FRAME, TEXT, RECTANGLE, GROUP, etc.", 36 | 37 | // Text content (only for text nodes) 38 | "text": "Content of text node", 39 | 40 | // CSS style object containing all style properties 41 | "cssStyles": { 42 | // Dimensions and position 43 | "width": "100px", 44 | "height": "50px", 45 | "position": "absolute", 46 | "left": "10px", 47 | "top": "20px", 48 | 49 | // Text styles (mainly for TEXT nodes) 50 | "fontFamily": "Inter", 51 | "fontSize": "16px", 52 | "fontWeight": 500, 53 | "textAlign": "center", 54 | "lineHeight": "24px", 55 | "color": "#333333", 56 | 57 | // Background and borders 58 | "backgroundColor": "#ffffff", 59 | "borderRadius": "8px", 60 | "border": "1px solid #eeeeee", 61 | 62 | // Effects 63 | "boxShadow": "0px 4px 8px rgba(0, 0, 0, 0.1)", 64 | 65 | // Other CSS properties... 66 | }, 67 | 68 | // Fill information (gradients, images, etc.) 69 | "fills": [ 70 | { 71 | "type": "SOLID", 72 | "color": "#ff0000", 73 | "opacity": 0.5 74 | } 75 | ], 76 | 77 | // Export information (for image and SVG nodes) 78 | "exportInfo": { 79 | "type": "IMAGE", 80 | "format": "PNG", 81 | "nodeId": "Node ID", 82 | "fileName": "suggested-file-name.png" 83 | }, 84 | 85 | // Child nodes 86 | "children": [ 87 | // Recursive node objects... 88 | ] 89 | } 90 | ] 91 | } 92 | ``` 93 | 94 | ### Data Structure Description 95 | 96 | #### SimplifiedDesign 97 | The top-level structure of the design file, containing basic information and all visible nodes. 98 | 99 | #### SimplifiedNode 100 | Represents an element in the design, which can be an artboard, frame, text, or shape. Key fields include: 101 | - `id`: Unique node identifier 102 | - `name`: Node name in Figma 103 | - `type`: Node type (FRAME, TEXT, RECTANGLE, etc.) 104 | - `text`: Text content (text nodes only) 105 | - `cssStyles`: CSS style object containing all style properties 106 | - `fills`: Fill information array 107 | - `exportInfo`: Export information (image and SVG nodes) 108 | - `children`: Array of child nodes 109 | 110 | ### CSSStyle 111 | Contains CSS style properties converted to web standards, such as fonts, colors, borders, shadows, etc. 112 | 113 | ### ExportInfo 114 | Export information for image and SVG nodes, including: 115 | - `type`: Export type (IMAGE or IMAGE_GROUP) 116 | - `format`: Recommended export format (PNG, JPG, SVG) 117 | - `nodeId`: Node ID for API calls 118 | - `fileName`: Suggested file name 119 | 120 | ## Installation and Usage 121 | 122 | ### Local Development and Packaging 123 | 124 | 1. Clone this repository 125 | 2. Install dependencies: `pnpm install` 126 | 3. Copy `.env.example` to `.env` and fill in your [Figma API access token](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens) 127 | 4. Local development: `pnpm run dev` 128 | 5. Build project: `pnpm run build` 129 | 6. Local packaging: `pnpm run publish:local` 130 | 131 | After packaging, a `.tgz` file will be generated in the project root directory, like `figma-mcp-server-1.0.0.tgz` 132 | 133 | ### Local Installation and Usage 134 | 135 | There are three ways to use this service: 136 | 137 | #### Method 1: Install from NPM (Recommended) 138 | 139 | ```bash 140 | # Global installation 141 | npm install -g @yhy2001/figma-mcp-server 142 | 143 | # Start the service 144 | figma-mcp --figma-api-key=<your-figma-api-key> 145 | ``` 146 | 147 | #### Method 2: Install from Local Package 148 | 149 | ```bash 150 | # Global installation of local package 151 | npm install -g ./figma-mcp-server-1.0.0.tgz 152 | 153 | # Start the service 154 | figma-mcp --figma-api-key=<your-figma-api-key> 155 | ``` 156 | 157 | #### Method 3: Use in a Project 158 | 159 | ```bash 160 | # Install in project 161 | npm install @yhy2001/figma-mcp-server --save 162 | 163 | # Add to package.json scripts 164 | # "start-figma-mcp": "figma-mcp --figma-api-key=<your-figma-api-key>" 165 | 166 | # Or run directly 167 | npx figma-mcp --figma-api-key=<your-figma-api-key> 168 | ``` 169 | 170 | ### Command Line Arguments 171 | 172 | - `--version`: Show version number 173 | - `--figma-api-key`: Your Figma API access token (required) 174 | - `--port`: Port for the server to run on (default: 3333) 175 | - `--stdio`: Run server in command mode instead of default HTTP/SSE mode 176 | - `--help`: Show help menu 177 | 178 | ## Connecting with AI Tools 179 | 180 | ### Using in Configuration Files 181 | 182 | Many tools like Cursor, Windsurf, and Claude Desktop use configuration files to start MCP servers. 183 | You can add the following to your configuration file: 184 | 185 | ```json 186 | # Use in MCP Client 187 | { 188 | "mcpServers": { 189 | "Figma MCP": { 190 | "command": "npx", 191 | "args": ["figma-mcp", "--figma-api-key=<your-figma-api-key>", "--stdio"] 192 | } 193 | } 194 | } 195 | 196 | # Use in Local 197 | { 198 | "mcpServers": { 199 | "Figma MCP": { 200 | "url": "http://localhost:3333/sse", 201 | "env": { 202 | "API_KEY": "<your-figma-api-key>" 203 | } 204 | } 205 | } 206 | } 207 | ``` 208 | 209 | ### Connecting with Cursor 210 | 211 | 1. Start the server: `figma-mcp --figma-api-key=<your-figma-api-key>` 212 | 2. Connect MCP server in Cursor's Settings → Features tab: `http://localhost:3333` 213 | 3. After confirming successful connection, use Composer in Agent mode 214 | 4. Paste Figma file link and ask Cursor to implement the design 215 | 216 | ## Available Tools 217 | 218 | The server provides the following MCP tools: 219 | 220 | ### get_figma_data 221 | 222 | Get information about a Figma file or specific node. 223 | 224 | Parameters: 225 | - `fileKey`: The key of the Figma file 226 | - `nodeId`: Node ID (strongly recommended) 227 | - `depth`: How deep to traverse the node tree 228 | 229 | ### download_figma_images 230 | 231 | Download image and icon resources from a Figma file. 232 | 233 | Parameters: 234 | - `fileKey`: The key of the Figma file containing the node 235 | - `nodes`: Array of image nodes to fetch 236 | - `localPath`: Directory path in the project where images are stored 237 | 238 | ## License 239 | 240 | MIT 241 | ``` -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { defineConfig } from "tsup"; 2 | 3 | const isDev = process.env.npm_lifecycle_event === "dev"; 4 | 5 | export default defineConfig({ 6 | clean: true, 7 | entry: ["src/index.ts"], 8 | format: ["esm"], 9 | minify: !isDev, 10 | target: "esnext", 11 | outDir: "dist", 12 | outExtension: ({ format }) => ({ 13 | js: ".js", 14 | }), 15 | onSuccess: isDev ? "node dist/index.js" : undefined, 16 | }); 17 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:lts-alpine 3 | 4 | # Install pnpm globally 5 | RUN npm install -g pnpm 6 | 7 | # Set working directory 8 | WORKDIR /app 9 | 10 | # Copy package files and install dependencies (cache layer) 11 | COPY package.json pnpm-lock.yaml ./ 12 | RUN pnpm install 13 | 14 | # Copy all source files 15 | COPY . . 16 | 17 | # Build the project 18 | RUN pnpm run build 19 | 20 | # Install this package globally so that the 'figma-mcp' command is available 21 | RUN npm install -g . 22 | 23 | # Expose the port (default 3333) 24 | EXPOSE 3333 25 | 26 | # Default command to run the MCP server 27 | CMD [ "figma-mcp", "--stdio" ] 28 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "~/*": ["./src/*"] 6 | }, 7 | 8 | "target": "ES2020", 9 | "lib": ["ES2021", "DOM"], 10 | "module": "NodeNext", 11 | "moduleResolution": "NodeNext", 12 | "resolveJsonModule": true, 13 | "allowJs": true, 14 | "checkJs": true, 15 | 16 | /* EMIT RULES */ 17 | "outDir": "./dist", 18 | "declaration": true, 19 | "declarationMap": true, 20 | "sourceMap": true, 21 | "removeComments": true, 22 | 23 | "strict": true, 24 | "esModuleInterop": true, 25 | "skipLibCheck": true, 26 | "forceConsistentCasingInFileNames": true 27 | }, 28 | "include": ["src/**/*", "test-*.ts", "test/test-simplify.ts", "script/custom/test-figma-api.ts", "script/custom/test-figma-comparison.ts", "script/custom/test-figma-data.ts"] 29 | } 30 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - figmaApiKey 10 | properties: 11 | figmaApiKey: 12 | type: string 13 | description: Your Figma API access token 14 | port: 15 | type: number 16 | default: 3333 17 | description: Port for the server to run on (default 3333) 18 | commandFunction: 19 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 20 | |- 21 | (config) => ({ 22 | command: 'figma-mcp', 23 | args: [`--figma-api-key=${config.figmaApiKey}`, '--stdio', `--port=${config.port}`], 24 | env: {} 25 | }) 26 | exampleConfig: 27 | figmaApiKey: dummy-figma-api-key 28 | port: 3333 29 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | import { FigmaMcpServer } from "./server.js"; 5 | import { getServerConfig } from "./config.js"; 6 | import { resolve } from "path"; 7 | import { config } from "dotenv"; 8 | import { fileURLToPath } from "url"; 9 | 10 | // Load .env from the current working directory 11 | config({ path: resolve(process.cwd(), ".env") }); 12 | 13 | export async function startServer(): Promise<void> { 14 | // Check if we're running in stdio mode (e.g., via CLI) 15 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); 16 | 17 | const config = getServerConfig(isStdioMode); 18 | 19 | const server = new FigmaMcpServer(config.figmaApiKey); 20 | 21 | if (isStdioMode) { 22 | const transport = new StdioServerTransport(); 23 | await server.connect(transport); 24 | } else { 25 | console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`); 26 | await server.startHttpServer(config.port); 27 | } 28 | } 29 | 30 | startServer().catch((error) => { 31 | console.error("Failed to start server:", error); 32 | process.exit(1); 33 | }); 34 | ``` -------------------------------------------------------------------------------- /src/transformers/style.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; 2 | import { SimplifiedFill } from "~/services/simplify-node-response.js"; 3 | import { generateCSSShorthand, isVisible, parsePaint } from "~/utils/common.js"; 4 | import { hasValue, isStrokeWeights } from "~/utils/identity.js"; 5 | export type SimplifiedStroke = { 6 | colors: SimplifiedFill[]; 7 | strokeWeight?: string; 8 | strokeDashes?: number[]; 9 | strokeWeights?: string; 10 | }; 11 | export function buildSimplifiedStrokes(n: FigmaDocumentNode): SimplifiedStroke { 12 | let strokes: SimplifiedStroke = { colors: [] }; 13 | if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) { 14 | strokes.colors = n.strokes.filter(isVisible).map(parsePaint); 15 | } 16 | 17 | if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) { 18 | strokes.strokeWeight = `${n.strokeWeight}px`; 19 | } 20 | 21 | if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) { 22 | strokes.strokeDashes = n.strokeDashes; 23 | } 24 | 25 | if (hasValue("individualStrokeWeights", n, isStrokeWeights)) { 26 | strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights); 27 | } 28 | 29 | return strokes; 30 | } 31 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@yhy2001/figma-mcp-server", 3 | "version": "1.0.1", 4 | "description": "本地MCP服务器,用于Figma设计与AI编码工具集成", 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "bin": { 8 | "figma-mcp": "dist/index.js" 9 | }, 10 | "files": [ 11 | "dist", 12 | "README.md" 13 | ], 14 | "scripts": { 15 | "dev": "cross-env NODE_ENV=development tsup --watch", 16 | "build": "tsup", 17 | "test:figma": "tsx test-figma-data.ts", 18 | "prepublishOnly": "npm run build", 19 | "start": "node dist/index.js", 20 | "inspect": "pnpx @modelcontextprotocol/inspector", 21 | "mcp-test": "pnpm start -- --stdio", 22 | "type-check": "tsc --noEmit", 23 | "start:cli": "cross-env NODE_ENV=cli node dist/index.js", 24 | "start:http": "node dist/index.js", 25 | "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio", 26 | "lint": "eslint . --ext .ts", 27 | "format": "prettier --write \"src/**/*.ts\"", 28 | "pub:release": "pnpm build && npm publish --access public", 29 | "publish:local": "pnpm build && npm pack" 30 | }, 31 | "engines": { 32 | "node": ">=18.0.0" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/1yhy/figma-mcp-server.git" 37 | }, 38 | "keywords": [ 39 | "figma", 40 | "mcp", 41 | "typescript", 42 | "ai", 43 | "design" 44 | ], 45 | "author": "", 46 | "license": "MIT", 47 | "dependencies": { 48 | "@modelcontextprotocol/sdk": "^1.6.1", 49 | "@types/yargs": "^17.0.33", 50 | "cross-env": "^7.0.3", 51 | "dotenv": "^16.4.7", 52 | "express": "^4.21.2", 53 | "remeda": "^2.20.1", 54 | "yargs": "^17.7.2", 55 | "zod": "^3.24.2" 56 | }, 57 | "devDependencies": { 58 | "@figma/rest-api-spec": "^0.24.0", 59 | "@types/express": "^5.0.0", 60 | "@types/jest": "^29.5.11", 61 | "@types/node": "^20.17.0", 62 | "@typescript-eslint/eslint-plugin": "^8.24.0", 63 | "@typescript-eslint/parser": "^8.24.0", 64 | "eslint": "^9.20.1", 65 | "eslint-config-prettier": "^10.0.1", 66 | "jest": "^29.7.0", 67 | "prettier": "^3.5.0", 68 | "ts-jest": "^29.2.5", 69 | "tsup": "^8.4.0", 70 | "tsx": "^4.19.2", 71 | "typescript": "^5.7.3" 72 | } 73 | } 74 | ``` -------------------------------------------------------------------------------- /src/transformers/effects.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | DropShadowEffect, 3 | InnerShadowEffect, 4 | BlurEffect, 5 | Node as FigmaDocumentNode, 6 | } from "@figma/rest-api-spec"; 7 | import { formatRGBAColor } from "~/utils/common.js"; 8 | import { hasValue } from "~/utils/identity.js"; 9 | 10 | export type SimplifiedEffects = { 11 | boxShadow?: string; 12 | filter?: string; 13 | backdropFilter?: string; 14 | }; 15 | 16 | export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { 17 | if (!hasValue("effects", n)) return {}; 18 | const effects = n.effects.filter((e) => e.visible); 19 | 20 | // Handle drop and inner shadows (both go into CSS box-shadow) 21 | const dropShadows = effects 22 | .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW") 23 | .map(simplifyDropShadow); 24 | 25 | const innerShadows = effects 26 | .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW") 27 | .map(simplifyInnerShadow); 28 | 29 | const boxShadow = [...dropShadows, ...innerShadows].join(", "); 30 | 31 | // Handle blur effects - separate by CSS property 32 | // Layer blurs use the CSS 'filter' property 33 | const filterBlurValues = effects 34 | .filter((e): e is BlurEffect => e.type === "LAYER_BLUR") 35 | .map(simplifyBlur) 36 | .join(" "); 37 | 38 | // Background blurs use the CSS 'backdrop-filter' property 39 | const backdropFilterValues = effects 40 | .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR") 41 | .map(simplifyBlur) 42 | .join(" "); 43 | 44 | const result: SimplifiedEffects = {}; 45 | if (boxShadow) result.boxShadow = boxShadow; 46 | if (filterBlurValues) result.filter = filterBlurValues; 47 | if (backdropFilterValues) result.backdropFilter = backdropFilterValues; 48 | 49 | return result; 50 | } 51 | 52 | function simplifyDropShadow(effect: DropShadowEffect) { 53 | return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; 54 | } 55 | 56 | function simplifyInnerShadow(effect: InnerShadowEffect) { 57 | return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; 58 | } 59 | 60 | function simplifyBlur(effect: BlurEffect) { 61 | return `blur(${effect.radius}px)`; 62 | } 63 | ``` -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { config } from "dotenv"; 2 | import yargs from "yargs"; 3 | import { hideBin } from "yargs/helpers"; 4 | 5 | // Load environment variables from .env file 6 | config(); 7 | 8 | interface ServerConfig { 9 | figmaApiKey: string; 10 | port: number; 11 | configSources: { 12 | figmaApiKey: "cli" | "env"; 13 | port: "cli" | "env" | "default"; 14 | }; 15 | } 16 | 17 | function maskApiKey(key: string): string { 18 | if (key.length <= 4) return "****"; 19 | return `****${key.slice(-4)}`; 20 | } 21 | 22 | interface CliArgs { 23 | "figma-api-key"?: string; 24 | port?: number; 25 | } 26 | 27 | export function getServerConfig(isStdioMode: boolean): ServerConfig { 28 | // Parse command line arguments 29 | const argv = yargs(hideBin(process.argv)) 30 | .options({ 31 | "figma-api-key": { 32 | type: "string", 33 | description: "Figma API key", 34 | }, 35 | port: { 36 | type: "number", 37 | description: "Port to run the server on", 38 | }, 39 | }) 40 | .help() 41 | .version("0.1.12") 42 | .parseSync() as CliArgs; 43 | 44 | const config: ServerConfig = { 45 | figmaApiKey: "", 46 | port: 3333, 47 | configSources: { 48 | figmaApiKey: "env", 49 | port: "default", 50 | }, 51 | }; 52 | 53 | // Handle FIGMA_API_KEY 54 | if (argv["figma-api-key"]) { 55 | config.figmaApiKey = argv["figma-api-key"]; 56 | config.configSources.figmaApiKey = "cli"; 57 | } else if (process.env.FIGMA_API_KEY) { 58 | config.figmaApiKey = process.env.FIGMA_API_KEY; 59 | config.configSources.figmaApiKey = "env"; 60 | } 61 | 62 | // Handle PORT 63 | if (argv.port) { 64 | config.port = argv.port; 65 | config.configSources.port = "cli"; 66 | } else if (process.env.PORT) { 67 | config.port = parseInt(process.env.PORT, 10); 68 | config.configSources.port = "env"; 69 | } 70 | 71 | // Validate configuration 72 | if (!config.figmaApiKey) { 73 | console.error("FIGMA_API_KEY is required (via CLI argument --figma-api-key or .env file)"); 74 | process.exit(1); 75 | } 76 | 77 | // Log configuration sources 78 | if (!isStdioMode) { 79 | console.log("\nConfiguration:"); 80 | console.log( 81 | `- FIGMA_API_KEY: ${maskApiKey(config.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`, 82 | ); 83 | console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`); 84 | console.log(); // Empty line for better readability 85 | } 86 | 87 | return config; 88 | } 89 | ``` -------------------------------------------------------------------------------- /src/utils/identity.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | Rectangle, 3 | HasLayoutTrait, 4 | StrokeWeights, 5 | HasFramePropertiesTrait, 6 | } from "@figma/rest-api-spec"; 7 | import { isTruthy } from "remeda"; 8 | import { CSSHexColor, CSSRGBAColor } from "~/services/simplify-node-response.js"; 9 | 10 | export { isTruthy }; 11 | 12 | export function hasValue<K extends PropertyKey, T>( 13 | key: K, 14 | obj: unknown, 15 | typeGuard?: (val: unknown) => val is T, 16 | ): obj is Record<K, T> { 17 | const isObject = typeof obj === "object" && obj !== null; 18 | if (!isObject || !(key in obj)) return false; 19 | const val = (obj as Record<K, unknown>)[key]; 20 | return typeGuard ? typeGuard(val) : val !== undefined; 21 | } 22 | 23 | export function isFrame(val: unknown): val is HasFramePropertiesTrait { 24 | return ( 25 | typeof val === "object" && 26 | !!val && 27 | "clipsContent" in val && 28 | typeof val.clipsContent === "boolean" 29 | ); 30 | } 31 | 32 | export function isLayout(val: unknown): val is HasLayoutTrait { 33 | return ( 34 | typeof val === "object" && 35 | !!val && 36 | "absoluteBoundingBox" in val && 37 | typeof val.absoluteBoundingBox === "object" && 38 | !!val.absoluteBoundingBox && 39 | "x" in val.absoluteBoundingBox && 40 | "y" in val.absoluteBoundingBox && 41 | "width" in val.absoluteBoundingBox && 42 | "height" in val.absoluteBoundingBox 43 | ); 44 | } 45 | 46 | export function isStrokeWeights(val: unknown): val is StrokeWeights { 47 | return ( 48 | typeof val === "object" && 49 | val !== null && 50 | "top" in val && 51 | "right" in val && 52 | "bottom" in val && 53 | "left" in val 54 | ); 55 | } 56 | 57 | export function isRectangle<T, K extends string>( 58 | key: K, 59 | obj: T, 60 | ): obj is T & { [P in K]: Rectangle } { 61 | const recordObj = obj as Record<K, unknown>; 62 | return ( 63 | typeof obj === "object" && 64 | !!obj && 65 | key in recordObj && 66 | typeof recordObj[key] === "object" && 67 | !!recordObj[key] && 68 | "x" in recordObj[key] && 69 | "y" in recordObj[key] && 70 | "width" in recordObj[key] && 71 | "height" in recordObj[key] 72 | ); 73 | } 74 | 75 | export function isRectangleCornerRadii(val: unknown): val is number[] { 76 | return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number"); 77 | } 78 | 79 | export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor { 80 | return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba")); 81 | } 82 | ``` -------------------------------------------------------------------------------- /src/services/figma.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | import { parseFigmaResponse, SimplifiedDesign } from "./simplify-node-response.js"; 3 | import type { 4 | GetImagesResponse, 5 | GetFileResponse, 6 | GetFileNodesResponse, 7 | GetImageFillsResponse, 8 | } from "@figma/rest-api-spec"; 9 | import { downloadFigmaImage } from "~/utils/common.js"; 10 | import { Logger } from "~/server.js"; 11 | 12 | export interface FigmaError { 13 | status: number; 14 | err: string; 15 | } 16 | 17 | type FetchImageParams = { 18 | /** 19 | * The Node in Figma that will either be rendered or have its background image downloaded 20 | */ 21 | nodeId: string; 22 | /** 23 | * The local file name to save the image 24 | */ 25 | fileName: string; 26 | /** 27 | * The file mimetype for the image 28 | */ 29 | fileType: "png" | "svg"; 30 | }; 31 | 32 | type FetchImageFillParams = Omit<FetchImageParams, "fileType"> & { 33 | /** 34 | * Required to grab the background image when an image is used as a fill 35 | */ 36 | imageRef: string; 37 | }; 38 | 39 | export class FigmaService { 40 | private readonly apiKey: string; 41 | private readonly baseUrl = "https://api.figma.com/v1"; 42 | 43 | constructor(apiKey: string) { 44 | this.apiKey = apiKey; 45 | } 46 | 47 | private async request<T>(endpoint: string): Promise<T> { 48 | if (typeof fetch !== "function") { 49 | throw new Error( 50 | "The MCP server is being run in a Node.js environment where `fetch` is not available. You won't be able to access any data related to the Figma file.\n\nAs the coding AI assistant, PLEASE ABORT THE CURRENT REQUEST. No alternate approaches will work. Help the user fix this issue so you can proceed by letting them know that they need to run the MCP server with Node.js version 18 or higher.", 51 | ); 52 | } 53 | try { 54 | Logger.log(`Calling ${this.baseUrl}${endpoint}`); 55 | const response = await fetch(`${this.baseUrl}${endpoint}`, { 56 | headers: { 57 | "X-Figma-Token": this.apiKey, 58 | }, 59 | }); 60 | 61 | if (!response.ok) { 62 | throw { 63 | status: response.status, 64 | err: response.statusText || "Unknown error", 65 | } as FigmaError; 66 | } 67 | 68 | return await response.json(); 69 | } catch (error) { 70 | if ((error as FigmaError).status) { 71 | throw error; 72 | } 73 | if (error instanceof Error) { 74 | throw new Error(`Failed to make request to Figma API: ${error.message}`); 75 | } 76 | throw new Error(`Failed to make request to Figma API: ${error}`); 77 | } 78 | } 79 | 80 | async getImageFills( 81 | fileKey: string, 82 | nodes: FetchImageFillParams[], 83 | localPath: string, 84 | ): Promise<string[]> { 85 | if (nodes.length === 0) return []; 86 | 87 | let promises: Promise<string>[] = []; 88 | const endpoint = `/files/${fileKey}/images`; 89 | const file = await this.request<GetImageFillsResponse>(endpoint); 90 | const { images = {} } = file.meta; 91 | promises = nodes.map(async ({ imageRef, fileName }) => { 92 | const imageUrl = images[imageRef]; 93 | if (!imageUrl) { 94 | return ""; 95 | } 96 | return downloadFigmaImage(fileName, localPath, imageUrl); 97 | }); 98 | return Promise.all(promises); 99 | } 100 | 101 | async getImages( 102 | fileKey: string, 103 | nodes: FetchImageParams[], 104 | localPath: string, 105 | ): Promise<string[]> { 106 | const pngIds = nodes.filter(({ fileType }) => fileType === "png").map(({ nodeId }) => nodeId); 107 | const pngFiles = 108 | pngIds.length > 0 109 | ? this.request<GetImagesResponse>( 110 | `/images/${fileKey}?ids=${pngIds.join(",")}&scale=2&format=png`, 111 | ).then(({ images = {} }) => images) 112 | : ({} as GetImagesResponse["images"]); 113 | 114 | const svgIds = nodes.filter(({ fileType }) => fileType === "svg").map(({ nodeId }) => nodeId); 115 | const svgFiles = 116 | svgIds.length > 0 117 | ? this.request<GetImagesResponse>( 118 | `/images/${fileKey}?ids=${svgIds.join(",")}&scale=2&format=svg`, 119 | ).then(({ images = {} }) => images) 120 | : ({} as GetImagesResponse["images"]); 121 | 122 | const files = await Promise.all([pngFiles, svgFiles]).then(([f, l]) => ({ ...f, ...l })); 123 | 124 | const downloads = nodes 125 | .map(({ nodeId, fileName }) => { 126 | const imageUrl = files[nodeId]; 127 | if (imageUrl) { 128 | return downloadFigmaImage(fileName, localPath, imageUrl); 129 | } 130 | return false; 131 | }) 132 | .filter((url) => !!url); 133 | 134 | return Promise.all(downloads); 135 | } 136 | 137 | async getFile(fileKey: string, depth?: number): Promise<SimplifiedDesign> { 138 | try { 139 | const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; 140 | Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`); 141 | const response = await this.request<GetFileResponse>(endpoint); 142 | Logger.log("Got response"); 143 | const simplifiedResponse = parseFigmaResponse(response); 144 | writeLogs("figma-raw.json", response); 145 | writeLogs("figma-simplified.json", simplifiedResponse); 146 | return simplifiedResponse; 147 | } catch (e) { 148 | console.error("Failed to get file:", e); 149 | throw e; 150 | } 151 | } 152 | 153 | async getNode(fileKey: string, nodeId: string, depth?: number): Promise<SimplifiedDesign> { 154 | const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; 155 | const response = await this.request<GetFileNodesResponse>(endpoint); 156 | Logger.log("Got response from getNode, now parsing."); 157 | writeLogs("figma-raw.json", response); 158 | const simplifiedResponse = parseFigmaResponse(response); 159 | writeLogs("figma-simplified.json", simplifiedResponse); 160 | return simplifiedResponse; 161 | } 162 | } 163 | 164 | function writeLogs(name: string, value: any) { 165 | try { 166 | if (process.env.NODE_ENV !== "development") return; 167 | 168 | const logsDir = "logs"; 169 | 170 | try { 171 | fs.accessSync(process.cwd(), fs.constants.W_OK); 172 | } catch (error) { 173 | Logger.log("Failed to write logs:", error); 174 | return; 175 | } 176 | 177 | if (!fs.existsSync(logsDir)) { 178 | fs.mkdirSync(logsDir); 179 | } 180 | fs.writeFileSync(`${logsDir}/${name}`, JSON.stringify(value, null, 2)); 181 | } catch (error) { 182 | console.debug("Failed to write logs:", error); 183 | } 184 | } 185 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { z } from "zod"; 3 | import { FigmaService } from "./services/figma.js"; 4 | import express, { Request, Response } from "express"; 5 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; 6 | import { IncomingMessage, ServerResponse } from "http"; 7 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 8 | import { SimplifiedDesign } from "./services/simplify-node-response.js"; 9 | 10 | export const Logger = { 11 | log: (...args: any[]) => {}, 12 | error: (...args: any[]) => {}, 13 | }; 14 | 15 | export class FigmaMcpServer { 16 | private readonly server: McpServer; 17 | private readonly figmaService: FigmaService; 18 | private sseTransport: SSEServerTransport | null = null; 19 | 20 | constructor(figmaApiKey: string) { 21 | this.figmaService = new FigmaService(figmaApiKey); 22 | this.server = new McpServer( 23 | { 24 | name: "Figma MCP Server", 25 | version: "0.1.12", 26 | }, 27 | { 28 | capabilities: { 29 | logging: {}, 30 | tools: {}, 31 | }, 32 | }, 33 | ); 34 | 35 | this.registerTools(); 36 | } 37 | 38 | private registerTools(): void { 39 | // Tool to get file information 40 | this.server.tool( 41 | "get_figma_data", 42 | "When the nodeId cannot be obtained, obtain the layout information about the entire Figma file", 43 | { 44 | fileKey: z 45 | .string() 46 | .describe( 47 | "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)/<fileKey>/...", 48 | ), 49 | nodeId: z 50 | .string() 51 | .optional() 52 | .describe( 53 | "The ID of the node to fetch, often found as URL parameter node-id=<nodeId>, always use if provided", 54 | ), 55 | depth: z 56 | .number() 57 | .optional() 58 | .describe( 59 | "How many levels deep to traverse the node tree, only use if explicitly requested by the user", 60 | ), 61 | }, 62 | async ({ fileKey, nodeId, depth }) => { 63 | try { 64 | Logger.log( 65 | `Fetching ${ 66 | depth ? `${depth} layers deep` : "all layers" 67 | } of ${nodeId ? `node ${nodeId} from file` : `full file`} ${fileKey}`, 68 | ); 69 | 70 | let file: SimplifiedDesign; 71 | if (nodeId) { 72 | file = await this.figmaService.getNode(fileKey, nodeId, depth); 73 | } else { 74 | file = await this.figmaService.getFile(fileKey, depth); 75 | } 76 | 77 | Logger.log(`Successfully fetched file: ${file.name}`); 78 | const { nodes, ...metadata } = file; 79 | 80 | // Stringify each node individually to try to avoid max string length error with big files 81 | const nodesJson = `[${nodes.map((node) => JSON.stringify(node, null, 2)).join(",")}]`; 82 | const metadataJson = JSON.stringify(metadata, null, 2); 83 | const resultJson = `{ "metadata": ${metadataJson}, "nodes": ${nodesJson} }`; 84 | 85 | return { 86 | content: [{ type: "text", text: resultJson }], 87 | }; 88 | } catch (error) { 89 | Logger.error(`Error fetching file ${fileKey}:`, error); 90 | return { 91 | isError: true, 92 | content: [{ type: "text", text: `Error fetching file: ${error}` }], 93 | }; 94 | } 95 | }, 96 | ); 97 | 98 | // TODO: Clean up all image download related code, particularly getImages in Figma service 99 | // Tool to download images 100 | this.server.tool( 101 | "download_figma_images", 102 | "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes", 103 | { 104 | fileKey: z.string().describe("The key of the Figma file containing the node"), 105 | nodes: z 106 | .object({ 107 | nodeId: z 108 | .string() 109 | .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), 110 | imageRef: z 111 | .string() 112 | .optional() 113 | .describe( 114 | "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.", 115 | ), 116 | fileName: z.string().describe("The local name for saving the fetched file"), 117 | }) 118 | .array() 119 | .describe("The nodes to fetch as images"), 120 | localPath: z 121 | .string() 122 | .describe( 123 | "The absolute path to the directory where images are stored in the project. Automatically creates directories if needed.", 124 | ), 125 | }, 126 | async ({ fileKey, nodes, localPath }) => { 127 | try { 128 | const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as { 129 | nodeId: string; 130 | imageRef: string; 131 | fileName: string; 132 | }[]; 133 | const fillDownloads = this.figmaService.getImageFills(fileKey, imageFills, localPath); 134 | const renderRequests = nodes 135 | .filter(({ imageRef }) => !imageRef) 136 | .map(({ nodeId, fileName }) => ({ 137 | nodeId, 138 | fileName, 139 | fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const), 140 | })); 141 | 142 | const renderDownloads = this.figmaService.getImages(fileKey, renderRequests, localPath); 143 | 144 | const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [ 145 | ...f, 146 | ...r, 147 | ]); 148 | 149 | // If any download fails, return false 150 | const saveSuccess = !downloads.find((success) => !success); 151 | return { 152 | content: [ 153 | { 154 | type: "text", 155 | text: saveSuccess 156 | ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}` 157 | : "Failed", 158 | }, 159 | ], 160 | }; 161 | } catch (error) { 162 | Logger.error(`Error downloading images from file ${fileKey}:`, error); 163 | return { 164 | isError: true, 165 | content: [{ type: "text", text: `Error downloading images: ${error}` }], 166 | }; 167 | } 168 | }, 169 | ); 170 | } 171 | 172 | async connect(transport: Transport): Promise<void> { 173 | // Logger.log("Connecting to transport..."); 174 | await this.server.connect(transport); 175 | 176 | Logger.log = (...args: any[]) => { 177 | this.server.server.sendLoggingMessage({ 178 | level: "info", 179 | data: args, 180 | }); 181 | }; 182 | Logger.error = (...args: any[]) => { 183 | this.server.server.sendLoggingMessage({ 184 | level: "error", 185 | data: args, 186 | }); 187 | }; 188 | 189 | Logger.log("Server connected and ready to process requests"); 190 | } 191 | 192 | async startHttpServer(port: number): Promise<void> { 193 | const app = express(); 194 | 195 | app.get("/sse", async (req: Request, res: Response) => { 196 | console.log("New SSE connection established"); 197 | this.sseTransport = new SSEServerTransport( 198 | "/messages", 199 | res as unknown as ServerResponse<IncomingMessage>, 200 | ); 201 | await this.server.connect(this.sseTransport); 202 | }); 203 | 204 | app.post("/messages", async (req: Request, res: Response) => { 205 | if (!this.sseTransport) { 206 | res.sendStatus(400); 207 | return; 208 | } 209 | await this.sseTransport.handlePostMessage( 210 | req as unknown as IncomingMessage, 211 | res as unknown as ServerResponse<IncomingMessage>, 212 | ); 213 | }); 214 | 215 | Logger.log = console.log; 216 | Logger.error = console.error; 217 | 218 | app.listen(port, () => { 219 | Logger.log(`HTTP server listening on port ${port}`); 220 | Logger.log(`SSE endpoint available at http://localhost:${port}/sse`); 221 | Logger.log(`Message endpoint available at http://localhost:${port}/messages`); 222 | }); 223 | } 224 | } 225 | ``` -------------------------------------------------------------------------------- /src/transformers/layout.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { isFrame, isLayout, isRectangle } from "~/utils/identity.js"; 2 | import type { 3 | Node as FigmaDocumentNode, 4 | HasFramePropertiesTrait, 5 | HasLayoutTrait, 6 | } from "@figma/rest-api-spec"; 7 | import { generateCSSShorthand } from "~/utils/common.js"; 8 | 9 | export interface SimplifiedLayout { 10 | mode: "none" | "row" | "column"; 11 | justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; 12 | alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; 13 | alignSelf?: "flex-start" | "flex-end" | "center" | "stretch"; 14 | wrap?: boolean; 15 | gap?: string; 16 | locationRelativeToParent?: { 17 | x: number; 18 | y: number; 19 | }; 20 | dimensions?: { 21 | width?: number; 22 | height?: number; 23 | aspectRatio?: number; 24 | }; 25 | padding?: string; 26 | sizing?: { 27 | horizontal?: "fixed" | "fill" | "hug"; 28 | vertical?: "fixed" | "fill" | "hug"; 29 | }; 30 | overflowScroll?: ("x" | "y")[]; 31 | position?: "absolute"; 32 | } 33 | 34 | // Convert Figma's layout config into a more typical flex-like schema 35 | export function buildSimplifiedLayout( 36 | n: FigmaDocumentNode, 37 | parent?: FigmaDocumentNode, 38 | ): SimplifiedLayout { 39 | const frameValues = buildSimplifiedFrameValues(n); 40 | const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {}; 41 | 42 | return { ...frameValues, ...layoutValues }; 43 | } 44 | 45 | // For flex layouts, process alignment and sizing 46 | function convertAlign( 47 | axisAlign?: 48 | | HasFramePropertiesTrait["primaryAxisAlignItems"] 49 | | HasFramePropertiesTrait["counterAxisAlignItems"], 50 | stretch?: { 51 | children: FigmaDocumentNode[]; 52 | axis: "primary" | "counter"; 53 | mode: "row" | "column" | "none"; 54 | }, 55 | ) { 56 | if (stretch && stretch.mode !== "none") { 57 | const { children, mode, axis } = stretch; 58 | 59 | // Compute whether to check horizontally or vertically based on axis and direction 60 | const direction = getDirection(axis, mode); 61 | 62 | const shouldStretch = 63 | children.length > 0 && 64 | children.reduce((shouldStretch, c) => { 65 | if (!shouldStretch) return false; 66 | if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true; 67 | if (direction === "horizontal") { 68 | return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL"; 69 | } else if (direction === "vertical") { 70 | return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL"; 71 | } 72 | return false; 73 | }, true); 74 | 75 | if (shouldStretch) return "stretch"; 76 | } 77 | 78 | switch (axisAlign) { 79 | case "MIN": 80 | // MIN, AKA flex-start, is the default alignment 81 | return undefined; 82 | case "MAX": 83 | return "flex-end"; 84 | case "CENTER": 85 | return "center"; 86 | case "SPACE_BETWEEN": 87 | return "space-between"; 88 | case "BASELINE": 89 | return "baseline"; 90 | default: 91 | return undefined; 92 | } 93 | } 94 | 95 | function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) { 96 | switch (align) { 97 | case "MIN": 98 | // MIN, AKA flex-start, is the default alignment 99 | return undefined; 100 | case "MAX": 101 | return "flex-end"; 102 | case "CENTER": 103 | return "center"; 104 | case "STRETCH": 105 | return "stretch"; 106 | default: 107 | return undefined; 108 | } 109 | } 110 | 111 | // interpret sizing 112 | function convertSizing( 113 | s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"], 114 | ) { 115 | if (s === "FIXED") return "fixed"; 116 | if (s === "FILL") return "fill"; 117 | if (s === "HUG") return "hug"; 118 | return undefined; 119 | } 120 | 121 | function getDirection( 122 | axis: "primary" | "counter", 123 | mode: "row" | "column", 124 | ): "horizontal" | "vertical" { 125 | switch (axis) { 126 | case "primary": 127 | switch (mode) { 128 | case "row": 129 | return "horizontal"; 130 | case "column": 131 | return "vertical"; 132 | } 133 | case "counter": 134 | switch (mode) { 135 | case "row": 136 | return "horizontal"; 137 | case "column": 138 | return "vertical"; 139 | } 140 | } 141 | } 142 | 143 | function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } { 144 | if (!isFrame(n)) { 145 | return { mode: "none" }; 146 | } 147 | 148 | const frameValues: SimplifiedLayout = { 149 | mode: 150 | !n.layoutMode || n.layoutMode === "NONE" 151 | ? "none" 152 | : n.layoutMode === "HORIZONTAL" 153 | ? "row" 154 | : "column", 155 | }; 156 | 157 | const overflowScroll: SimplifiedLayout["overflowScroll"] = []; 158 | if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x"); 159 | if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y"); 160 | if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll; 161 | 162 | if (frameValues.mode === "none") { 163 | return frameValues; 164 | } 165 | 166 | // TODO: convertAlign should be two functions, one for justifyContent and one for alignItems 167 | frameValues.justifyContent = convertAlign(n.primaryAxisAlignItems ?? "MIN", { 168 | children: n.children, 169 | axis: "primary", 170 | mode: frameValues.mode, 171 | }); 172 | frameValues.alignItems = convertAlign(n.counterAxisAlignItems ?? "MIN", { 173 | children: n.children, 174 | axis: "counter", 175 | mode: frameValues.mode, 176 | }); 177 | frameValues.alignSelf = convertSelfAlign(n.layoutAlign); 178 | 179 | // Only include wrap if it's set to WRAP, since flex layouts don't default to wrapping 180 | frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined; 181 | frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : undefined; 182 | // gather padding 183 | if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) { 184 | frameValues.padding = generateCSSShorthand({ 185 | top: n.paddingTop ?? 0, 186 | right: n.paddingRight ?? 0, 187 | bottom: n.paddingBottom ?? 0, 188 | left: n.paddingLeft ?? 0, 189 | }); 190 | } 191 | 192 | return frameValues; 193 | } 194 | 195 | function buildSimplifiedLayoutValues( 196 | n: FigmaDocumentNode, 197 | parent: FigmaDocumentNode | undefined, 198 | mode: "row" | "column" | "none", 199 | ): SimplifiedLayout | undefined { 200 | if (!isLayout(n)) return undefined; 201 | 202 | const layoutValues: SimplifiedLayout = { mode }; 203 | 204 | layoutValues.sizing = { 205 | horizontal: convertSizing(n.layoutSizingHorizontal), 206 | vertical: convertSizing(n.layoutSizingVertical), 207 | }; 208 | 209 | // Only include positioning-related properties if parent layout isn't flex or if the node is absolute 210 | if (isFrame(parent) && (parent?.layoutMode === "NONE" || n.layoutPositioning === "ABSOLUTE")) { 211 | if (n.layoutPositioning === "ABSOLUTE") { 212 | layoutValues.position = "absolute"; 213 | } 214 | if (n.absoluteBoundingBox && parent.absoluteBoundingBox) { 215 | layoutValues.locationRelativeToParent = { 216 | x: n.absoluteBoundingBox.x - (parent?.absoluteBoundingBox?.x ?? n.absoluteBoundingBox.x), 217 | y: n.absoluteBoundingBox.y - (parent?.absoluteBoundingBox?.y ?? n.absoluteBoundingBox.y), 218 | }; 219 | } 220 | return layoutValues; 221 | } 222 | 223 | // Handle dimensions based on layout growth and alignment 224 | if (isRectangle("absoluteBoundingBox", n) && isRectangle("absoluteBoundingBox", parent)) { 225 | const dimensions: { width?: number; height?: number; aspectRatio?: number } = {}; 226 | 227 | // Only include dimensions that aren't meant to stretch 228 | if (mode === "row") { 229 | if (!n.layoutGrow && n.layoutSizingHorizontal == "FIXED") 230 | dimensions.width = n.absoluteBoundingBox.width; 231 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical == "FIXED") 232 | dimensions.height = n.absoluteBoundingBox.height; 233 | } else if (mode === "column") { 234 | // column 235 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal == "FIXED") 236 | dimensions.width = n.absoluteBoundingBox.width; 237 | if (!n.layoutGrow && n.layoutSizingVertical == "FIXED") 238 | dimensions.height = n.absoluteBoundingBox.height; 239 | 240 | if (n.preserveRatio) { 241 | dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height; 242 | } 243 | } 244 | 245 | if (Object.keys(dimensions).length > 0) { 246 | layoutValues.dimensions = dimensions; 247 | } 248 | } 249 | 250 | return layoutValues; 251 | } 252 | ``` -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | import type { Paint, RGBA } from "@figma/rest-api-spec"; 5 | import { CSSHexColor, CSSRGBAColor, SimplifiedFill } from "~/services/simplify-node-response.js"; 6 | 7 | export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; 8 | 9 | export interface ColorValue { 10 | hex: CSSHexColor; 11 | opacity: number; 12 | } 13 | 14 | /** 15 | * Download Figma image and save it locally 16 | * @param fileName - The filename to save as 17 | * @param localPath - The local path to save to 18 | * @param imageUrl - Image URL (images[nodeId]) 19 | * @returns A Promise that resolves to the full file path where the image was saved 20 | * @throws Error if download fails 21 | */ 22 | export async function downloadFigmaImage( 23 | fileName: string, 24 | localPath: string, 25 | imageUrl: string, 26 | ): Promise<string> { 27 | try { 28 | // Ensure local path exists 29 | if (!fs.existsSync(localPath)) { 30 | fs.mkdirSync(localPath, { recursive: true }); 31 | } 32 | 33 | // Build the complete file path 34 | const fullPath = path.join(localPath, fileName); 35 | 36 | // Use fetch to download the image 37 | const response = await fetch(imageUrl, { 38 | method: "GET", 39 | }); 40 | 41 | if (!response.ok) { 42 | throw new Error(`Failed to download image: ${response.statusText}`); 43 | } 44 | 45 | // Create write stream 46 | const writer = fs.createWriteStream(fullPath); 47 | 48 | // Get the response as a readable stream and pipe it to the file 49 | const reader = response.body?.getReader(); 50 | if (!reader) { 51 | throw new Error("Failed to get response body"); 52 | } 53 | 54 | return new Promise((resolve, reject) => { 55 | // Process stream 56 | const processStream = async () => { 57 | try { 58 | while (true) { 59 | const { done, value } = await reader.read(); 60 | if (done) { 61 | writer.end(); 62 | break; 63 | } 64 | writer.write(value); 65 | } 66 | resolve(fullPath); 67 | } catch (err) { 68 | writer.end(); 69 | fs.unlink(fullPath, () => {}); 70 | reject(err); 71 | } 72 | }; 73 | 74 | writer.on("error", (err) => { 75 | reader.cancel(); 76 | fs.unlink(fullPath, () => {}); 77 | reject(new Error(`Failed to write image: ${err.message}`)); 78 | }); 79 | 80 | processStream(); 81 | }); 82 | } catch (error) { 83 | const errorMessage = error instanceof Error ? error.message : String(error); 84 | throw new Error(`Error downloading image: ${errorMessage}`); 85 | } 86 | } 87 | 88 | /** 89 | * Remove keys with empty arrays or empty objects from an object. 90 | * @param input - The input object or value. 91 | * @returns The processed object or the original value. 92 | */ 93 | export function removeEmptyKeys<T>(input: T): T { 94 | // If not an object type or null, return directly 95 | if (typeof input !== "object" || input === null) { 96 | return input; 97 | } 98 | 99 | // Handle array type 100 | if (Array.isArray(input)) { 101 | return input.map((item) => removeEmptyKeys(item)) as T; 102 | } 103 | 104 | // Handle object type 105 | const result = {} as T; 106 | for (const key in input) { 107 | if (Object.prototype.hasOwnProperty.call(input, key)) { 108 | const value = input[key]; 109 | 110 | // Recursively process nested objects 111 | const cleanedValue = removeEmptyKeys(value); 112 | 113 | // Skip empty arrays and empty objects 114 | if ( 115 | cleanedValue !== undefined && 116 | !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && 117 | !( 118 | typeof cleanedValue === "object" && 119 | cleanedValue !== null && 120 | Object.keys(cleanedValue).length === 0 121 | ) 122 | ) { 123 | result[key] = cleanedValue; 124 | } 125 | } 126 | } 127 | 128 | return result; 129 | } 130 | 131 | /** 132 | * Convert hex color value and opacity to rgba format 133 | * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00") 134 | * @param opacity - Opacity value (0-1) 135 | * @returns Color string in rgba format 136 | */ 137 | export function hexToRgba(hex: string, opacity: number = 1): string { 138 | // Remove possible # prefix 139 | hex = hex.replace("#", ""); 140 | 141 | // Handle shorthand hex values (e.g., #FFF) 142 | if (hex.length === 3) { 143 | hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; 144 | } 145 | 146 | // Convert hex to RGB values 147 | const r = parseInt(hex.substring(0, 2), 16); 148 | const g = parseInt(hex.substring(2, 4), 16); 149 | const b = parseInt(hex.substring(4, 6), 16); 150 | 151 | // Ensure opacity is in the 0-1 range 152 | const validOpacity = Math.min(Math.max(opacity, 0), 1); 153 | 154 | return `rgba(${r}, ${g}, ${b}, ${validOpacity})`; 155 | } 156 | 157 | /** 158 | * Convert color from RGBA to { hex, opacity } 159 | * 160 | * @param color - The color to convert, including alpha channel 161 | * @param opacity - The opacity of the color, if not included in alpha channel 162 | * @returns The converted color 163 | **/ 164 | export function convertColor(color: RGBA, opacity = 1): ColorValue { 165 | const r = Math.round(color.r * 255); 166 | const g = Math.round(color.g * 255); 167 | const b = Math.round(color.b * 255); 168 | 169 | // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative 170 | const a = Math.round(opacity * color.a * 100) / 100; 171 | 172 | const hex = ("#" + 173 | ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor; 174 | 175 | return { hex, opacity: a }; 176 | } 177 | 178 | /** 179 | * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format 180 | * 181 | * @param color - The color to convert, including alpha channel 182 | * @param opacity - The opacity of the color, if not included in alpha channel 183 | * @returns The converted color 184 | **/ 185 | export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor { 186 | const r = Math.round(color.r * 255); 187 | const g = Math.round(color.g * 255); 188 | const b = Math.round(color.b * 255); 189 | // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative 190 | const a = Math.round(opacity * color.a * 100) / 100; 191 | 192 | return `rgba(${r}, ${g}, ${b}, ${a})`; 193 | } 194 | 195 | /** 196 | * Generate a 6-character random variable ID 197 | * @param prefix - ID prefix 198 | * @returns A 6-character random ID string with prefix 199 | */ 200 | export function generateVarId(prefix: string = "var"): StyleId { 201 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 202 | let result = ""; 203 | 204 | for (let i = 0; i < 6; i++) { 205 | const randomIndex = Math.floor(Math.random() * chars.length); 206 | result += chars[randomIndex]; 207 | } 208 | 209 | return `${prefix}_${result}` as StyleId; 210 | } 211 | 212 | /** 213 | * Generate a CSS shorthand for values that come with top, right, bottom, and left 214 | * 215 | * input: { top: 10, right: 10, bottom: 10, left: 10 } 216 | * output: "10px" 217 | * 218 | * input: { top: 10, right: 20, bottom: 10, left: 20 } 219 | * output: "10px 20px" 220 | * 221 | * input: { top: 10, right: 20, bottom: 30, left: 40 } 222 | * output: "10px 20px 30px 40px" 223 | * 224 | * @param values - The values to generate the shorthand for 225 | * @returns The generated shorthand 226 | */ 227 | export function generateCSSShorthand( 228 | values: { 229 | top: number; 230 | right: number; 231 | bottom: number; 232 | left: number; 233 | }, 234 | { 235 | ignoreZero = true, 236 | suffix = "px", 237 | }: { 238 | /** 239 | * If true and all values are 0, return undefined. Defaults to true. 240 | */ 241 | ignoreZero?: boolean; 242 | /** 243 | * The suffix to add to the shorthand. Defaults to "px". 244 | */ 245 | suffix?: string; 246 | } = {}, 247 | ) { 248 | const { top, right, bottom, left } = values; 249 | if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) { 250 | return undefined; 251 | } 252 | if (top === right && right === bottom && bottom === left) { 253 | return `${top}${suffix}`; 254 | } 255 | if (right === left) { 256 | if (top === bottom) { 257 | return `${top}${suffix} ${right}${suffix}`; 258 | } 259 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`; 260 | } 261 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`; 262 | } 263 | 264 | /** 265 | * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill 266 | * @param raw - The Figma paint to convert 267 | * @returns The converted SimplifiedFill 268 | */ 269 | export function parsePaint(raw: Paint): SimplifiedFill { 270 | if (raw.type === "IMAGE") { 271 | return { 272 | type: "IMAGE", 273 | imageRef: raw.imageRef, 274 | scaleMode: raw.scaleMode, 275 | }; 276 | } else if (raw.type === "SOLID") { 277 | // treat as SOLID 278 | const { hex, opacity } = convertColor(raw.color!, raw.opacity); 279 | if (opacity === 1) { 280 | return hex; 281 | } else { 282 | return formatRGBAColor(raw.color!, opacity); 283 | } 284 | } else if ( 285 | ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( 286 | raw.type, 287 | ) 288 | ) { 289 | // treat as GRADIENT_LINEAR 290 | return { 291 | type: raw.type, 292 | gradientHandlePositions: raw.gradientHandlePositions, 293 | gradientStops: raw.gradientStops.map(({ position, color }) => ({ 294 | position, 295 | color: convertColor(color), 296 | })), 297 | }; 298 | } else { 299 | throw new Error(`Unknown paint type: ${raw.type}`); 300 | } 301 | } 302 | 303 | /** 304 | * 检查元素是否可见 305 | * @param element - 要检查的元素 306 | * @returns 如果元素可见则返回true,否则返回false 307 | */ 308 | export function isVisible(element: { 309 | visible?: boolean; 310 | opacity?: number; 311 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; 312 | absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null; 313 | }): boolean { 314 | // 1. 显式可见性检查 315 | if (element.visible === false) { 316 | return false; 317 | } 318 | 319 | // 2. 透明度检查 320 | if (element.opacity === 0) { 321 | return false; 322 | } 323 | 324 | // 3. 渲染边界检查 - 如果明确没有渲染边界,则不可见 325 | if (element.absoluteRenderBounds === null) { 326 | return false; 327 | } 328 | 329 | // 默认为可见 330 | return true; 331 | } 332 | 333 | /** 334 | * 检查元素在父容器中是否可见 335 | * @param element - 要检查的元素 336 | * @param parent - 父元素信息 337 | * @returns 如果元素可见则返回true,否则返回false 338 | */ 339 | export function isVisibleInParent( 340 | element: { 341 | visible?: boolean; 342 | opacity?: number; 343 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; 344 | absoluteRenderBounds?: { x: number; y: number; width: number; height: number } | null; 345 | }, 346 | parent: { 347 | clipsContent?: boolean; 348 | absoluteBoundingBox?: { x: number; y: number; width: number; height: number }; 349 | } 350 | ): boolean { 351 | // 先检查元素本身是否可见 352 | if (!isVisible(element)) { 353 | return false; 354 | } 355 | 356 | // 父容器裁剪检查 357 | if (parent && 358 | parent.clipsContent === true && 359 | element.absoluteBoundingBox && 360 | parent.absoluteBoundingBox) { 361 | 362 | const elementBox = element.absoluteBoundingBox; 363 | const parentBox = parent.absoluteBoundingBox; 364 | 365 | // 检查元素是否完全在父容器外部 366 | const outsideParent = 367 | elementBox.x >= parentBox.x + parentBox.width || // 右侧超出 368 | elementBox.x + elementBox.width <= parentBox.x || // 左侧超出 369 | elementBox.y >= parentBox.y + parentBox.height || // 底部超出 370 | elementBox.y + elementBox.height <= parentBox.y; // 顶部超出 371 | 372 | if (outsideParent) { 373 | return false; 374 | } 375 | } 376 | 377 | // 通过所有检查,认为元素可见 378 | return true; 379 | } 380 | ``` -------------------------------------------------------------------------------- /test/test-output/simplified-node-data.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "test", 3 | "lastModified": "2025-03-25T08:22:10Z", 4 | "thumbnailUrl": "https://s3-alpha.figma.com/thumbnails/473a648c-4039-40c2-8046-1e1a2030aa81?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQ4GOSFWCT3MRUCDU%2F20250323%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250323T120000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=016073092878d75d2d8b7e2fc2425fd97579e7f97d0d33fd89cd770cd30cc65a", 5 | "nodes": [ 6 | { 7 | "id": "409:3528", 8 | "name": "Frame", 9 | "type": "FRAME", 10 | "cssStyles": { 11 | "width": "322px", 12 | "height": "523px", 13 | "position": "absolute", 14 | "left": "13338px", 15 | "top": "12889px", 16 | "borderColor": "rgba(217, 217, 217, 0.4)", 17 | "borderWidth": "1px", 18 | "borderStyle": "solid", 19 | "borderRadius": "10px" 20 | }, 21 | "children": [ 22 | { 23 | "id": "409:3529", 24 | "name": "Group", 25 | "type": "GROUP", 26 | "cssStyles": { 27 | "width": "275.9183044433594px", 28 | "height": "275.53729248046875px", 29 | "position": "absolute", 30 | "left": "26.6875px", 31 | "top": "230.3779296875px" 32 | }, 33 | "children": [ 34 | { 35 | "id": "409:3530", 36 | "name": "Group", 37 | "type": "GROUP", 38 | "cssStyles": { 39 | "width": "275.9183044433594px", 40 | "height": "275.53729248046875px", 41 | "position": "absolute", 42 | "left": "0px", 43 | "top": "0px", 44 | "display": "flex", 45 | "flexDirection": "column", 46 | "gap": "9px", 47 | "justifyContent": "space-between" 48 | }, 49 | "children": [ 50 | { 51 | "id": "409:3565", 52 | "name": "Group 1410104421", 53 | "type": "GROUP", 54 | "cssStyles": { 55 | "width": "47.074562072753906px", 56 | "height": "47.074562072753906px", 57 | "position": "absolute", 58 | "left": "228.84375px", 59 | "top": "0px" 60 | }, 61 | "exportInfo": { 62 | "type": "IMAGE", 63 | "format": "SVG", 64 | "nodeId": "409:3565", 65 | "fileName": "group_1410104421.svg" 66 | } 67 | }, 68 | { 69 | "id": "409:3555", 70 | "name": "Vector", 71 | "type": "VECTOR", 72 | "cssStyles": { 73 | "width": "28.830785751342773px", 74 | "height": "24.948415756225586px", 75 | "position": "absolute", 76 | "left": "237.98828125px", 77 | "top": "66.38671875px", 78 | "backgroundColor": "#FFFFFF" 79 | }, 80 | "exportInfo": { 81 | "type": "IMAGE", 82 | "format": "SVG", 83 | "nodeId": "409:3555" 84 | } 85 | }, 86 | { 87 | "id": "409:3539", 88 | "name": "500 K", 89 | "type": "TEXT", 90 | "cssStyles": { 91 | "width": "27px", 92 | "height": "11px", 93 | "position": "absolute", 94 | "left": "239.251953125px", 95 | "top": "97.736328125px", 96 | "color": "#FFFFFF", 97 | "fontFamily": "Montserrat", 98 | "fontSize": "8.899900436401367px", 99 | "fontWeight": 600, 100 | "textAlign": "left", 101 | "verticalAlign": "top", 102 | "lineHeight": "10.848978042602539px" 103 | }, 104 | "text": "500 K" 105 | }, 106 | { 107 | "id": "409:3550", 108 | "name": "Group", 109 | "type": "GROUP", 110 | "cssStyles": { 111 | "width": "28.695743560791016px", 112 | "height": "29.134611129760742px", 113 | "position": "absolute", 114 | "left": "238.0390625px", 115 | "top": "120.115234375px" 116 | }, 117 | "exportInfo": { 118 | "type": "IMAGE", 119 | "format": "SVG", 120 | "nodeId": "409:3550", 121 | "fileName": "group.svg" 122 | } 123 | }, 124 | { 125 | "id": "409:3537", 126 | "name": "400", 127 | "type": "TEXT", 128 | "cssStyles": { 129 | "width": "19px", 130 | "height": "11px", 131 | "position": "absolute", 132 | "left": "243.90625px", 133 | "top": "155.279296875px", 134 | "color": "#FFFFFF", 135 | "fontFamily": "Montserrat", 136 | "fontSize": "8.899900436401367px", 137 | "fontWeight": 600, 138 | "textAlign": "left", 139 | "verticalAlign": "top", 140 | "lineHeight": "10.848978042602539px" 141 | }, 142 | "text": "400" 143 | }, 144 | { 145 | "id": "409:3549", 146 | "name": "Vector", 147 | "type": "VECTOR", 148 | "cssStyles": { 149 | "width": "25.04969024658203px", 150 | "height": "25.30290412902832px", 151 | "position": "absolute", 152 | "left": "239.87890625px", 153 | "top": "175.970703125px", 154 | "backgroundColor": "#FFFFFF" 155 | }, 156 | "exportInfo": { 157 | "type": "IMAGE", 158 | "format": "SVG", 159 | "nodeId": "409:3549" 160 | } 161 | }, 162 | { 163 | "id": "409:3562", 164 | "name": "Vector", 165 | "type": "VECTOR", 166 | "cssStyles": { 167 | "width": "32.74707794189453px", 168 | "height": "14.19596004486084px", 169 | "position": "absolute", 170 | "left": "2.17578125px", 171 | "top": "204.7001953125px", 172 | "backgroundColor": "#F7214E" 173 | }, 174 | "exportInfo": { 175 | "type": "IMAGE", 176 | "format": "SVG", 177 | "nodeId": "409:3562" 178 | } 179 | }, 180 | { 181 | "id": "409:3563", 182 | "name": "HOT", 183 | "type": "TEXT", 184 | "cssStyles": { 185 | "width": "21px", 186 | "height": "11px", 187 | "position": "absolute", 188 | "left": "7.625px", 189 | "top": "205.7548828125px", 190 | "color": "#FFFFFF", 191 | "fontFamily": "Montserrat", 192 | "fontSize": "8.823772430419922px", 193 | "fontWeight": 800, 194 | "textAlign": "left", 195 | "verticalAlign": "top", 196 | "lineHeight": "10.756178855895996px" 197 | }, 198 | "text": "HOT" 199 | }, 200 | { 201 | "id": "409:3535", 202 | "name": "Share", 203 | "type": "TEXT", 204 | "cssStyles": { 205 | "width": "27px", 206 | "height": "11px", 207 | "position": "absolute", 208 | "left": "239.251953125px", 209 | "top": "206.9052734375px", 210 | "color": "#FFFFFF", 211 | "fontFamily": "Montserrat", 212 | "fontSize": "8.899900436401367px", 213 | "fontWeight": 600, 214 | "textAlign": "left", 215 | "verticalAlign": "top", 216 | "lineHeight": "10.848978042602539px" 217 | }, 218 | "text": "Share" 219 | }, 220 | { 221 | "id": "409:3533", 222 | "name": "@LoremIpsum", 223 | "type": "TEXT", 224 | "cssStyles": { 225 | "width": "86px", 226 | "height": "14px", 227 | "position": "absolute", 228 | "left": "0px", 229 | "top": "223.7900390625px", 230 | "color": "#FFFFFF", 231 | "fontFamily": "Montserrat", 232 | "fontSize": "11.237085342407227px", 233 | "fontWeight": 600, 234 | "textAlign": "left", 235 | "verticalAlign": "top", 236 | "lineHeight": "13.698006629943848px" 237 | }, 238 | "text": "@LoremIpsum" 239 | }, 240 | { 241 | "id": "409:3542", 242 | "name": "Group", 243 | "type": "GROUP", 244 | "cssStyles": { 245 | "width": "34.25025939941406px", 246 | "height": "34.249176025390625px", 247 | "position": "absolute", 248 | "left": "235.26953125px", 249 | "top": "237.2109375px" 250 | }, 251 | "exportInfo": { 252 | "type": "IMAGE", 253 | "format": "SVG", 254 | "nodeId": "409:3542", 255 | "fileName": "group.svg" 256 | } 257 | }, 258 | { 259 | "id": "409:3541", 260 | "name": "#lorem#Ipsum#loremipsum", 261 | "type": "TEXT", 262 | "cssStyles": { 263 | "width": "165px", 264 | "height": "14px", 265 | "position": "absolute", 266 | "left": "0px", 267 | "top": "238.474609375px", 268 | "color": "#FFFFFF", 269 | "fontFamily": "Montserrat", 270 | "fontSize": "11.237085342407227px", 271 | "fontWeight": 600, 272 | "textAlign": "left", 273 | "verticalAlign": "top", 274 | "lineHeight": "13.698006629943848px" 275 | }, 276 | "text": "#lorem#Ipsum#loremipsum" 277 | }, 278 | { 279 | "id": "409:3531", 280 | "name": "Song Title - Singer", 281 | "type": "TEXT", 282 | "cssStyles": { 283 | "width": "106px", 284 | "height": "14px", 285 | "position": "absolute", 286 | "left": "27.716796875px", 287 | "top": "261.537109375px", 288 | "color": "#FFFFFF", 289 | "fontFamily": "Montserrat", 290 | "fontSize": "11.237085342407227px", 291 | "fontWeight": 600, 292 | "textAlign": "left", 293 | "verticalAlign": "top", 294 | "lineHeight": "13.698006629943848px" 295 | }, 296 | "text": "Song Title - Singer" 297 | }, 298 | { 299 | "id": "409:3558", 300 | "name": "Vector", 301 | "type": "VECTOR", 302 | "cssStyles": { 303 | "width": "12.102851867675781px", 304 | "height": "12.069082260131836px", 305 | "position": "absolute", 306 | "left": "4.490234375px", 307 | "top": "262.564453125px", 308 | "backgroundColor": "#FFFFFF" 309 | }, 310 | "exportInfo": { 311 | "type": "IMAGE", 312 | "format": "SVG", 313 | "nodeId": "409:3558" 314 | } 315 | } 316 | ] 317 | } 318 | ] 319 | } 320 | ] 321 | } 322 | ] 323 | } ``` -------------------------------------------------------------------------------- /test/test-output/viewer.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="zh-CN"> 3 | 4 | <head> 5 | <meta charset="UTF-8"> 6 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 | <title>Figma 节点 CSS 样式查看器</title> 8 | <style> 9 | :root { 10 | --primary-color: #1E88E5; 11 | --secondary-color: #757575; 12 | --background-color: #FAFAFA; 13 | --card-background: #FFFFFF; 14 | --border-color: #E0E0E0; 15 | } 16 | 17 | body { 18 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; 19 | margin: 0; 20 | padding: 20px; 21 | background-color: var(--background-color); 22 | color: #333; 23 | } 24 | 25 | .container { 26 | max-width: 1200px; 27 | margin: 0 auto; 28 | background-color: var(--card-background); 29 | border-radius: 8px; 30 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 31 | padding: 20px; 32 | } 33 | 34 | h1 { 35 | color: var(--primary-color); 36 | border-bottom: 1px solid var(--border-color); 37 | padding-bottom: 10px; 38 | margin-top: 0; 39 | } 40 | 41 | .info-box { 42 | background-color: #E3F2FD; 43 | border-left: 4px solid var(--primary-color); 44 | padding: 10px 15px; 45 | margin-bottom: 20px; 46 | border-radius: 4px; 47 | } 48 | 49 | .file-input-container { 50 | display: flex; 51 | margin-bottom: 20px; 52 | align-items: center; 53 | flex-wrap: wrap; 54 | gap: 10px; 55 | } 56 | 57 | input[type="file"] { 58 | flex: 1; 59 | min-width: 300px; 60 | padding: 8px; 61 | border: 1px solid var(--border-color); 62 | border-radius: 4px; 63 | } 64 | 65 | button { 66 | background-color: var(--primary-color); 67 | color: white; 68 | border: none; 69 | padding: 8px 16px; 70 | border-radius: 4px; 71 | cursor: pointer; 72 | transition: background-color 0.2s; 73 | } 74 | 75 | button:hover { 76 | background-color: #1565C0; 77 | } 78 | 79 | .tabs { 80 | display: flex; 81 | margin-bottom: 20px; 82 | border-bottom: 1px solid var(--border-color); 83 | } 84 | 85 | .tab { 86 | padding: 10px 20px; 87 | cursor: pointer; 88 | border-bottom: 2px solid transparent; 89 | } 90 | 91 | .tab.active { 92 | color: var(--primary-color); 93 | border-bottom: 2px solid var(--primary-color); 94 | font-weight: 500; 95 | } 96 | 97 | .tab-content { 98 | display: none; 99 | } 100 | 101 | .tab-content.active { 102 | display: block; 103 | } 104 | 105 | .nodes { 106 | font-family: monospace; 107 | white-space: pre-wrap; 108 | padding: 15px; 109 | background-color: #F5F5F5; 110 | border-radius: 4px; 111 | overflow: auto; 112 | max-height: 600px; 113 | } 114 | 115 | .css-styles { 116 | display: grid; 117 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 118 | gap: 20px; 119 | } 120 | 121 | .style-card { 122 | border: 1px solid var(--border-color); 123 | border-radius: 6px; 124 | overflow: hidden; 125 | } 126 | 127 | .style-preview { 128 | height: 120px; 129 | display: flex; 130 | justify-content: center; 131 | align-items: center; 132 | } 133 | 134 | .style-info { 135 | padding: 15px; 136 | border-top: 1px solid var(--border-color); 137 | background-color: #F5F5F5; 138 | } 139 | 140 | .style-name { 141 | font-weight: 500; 142 | margin-bottom: 5px; 143 | } 144 | 145 | .style-properties { 146 | font-family: monospace; 147 | font-size: 13px; 148 | } 149 | 150 | .property { 151 | margin: 3px 0; 152 | } 153 | 154 | .color-box { 155 | display: inline-block; 156 | width: 16px; 157 | height: 16px; 158 | border-radius: 3px; 159 | margin-right: 6px; 160 | vertical-align: middle; 161 | border: 1px solid rgba(0, 0, 0, 0.1); 162 | } 163 | 164 | .search-container { 165 | margin-bottom: 15px; 166 | } 167 | 168 | #nodeSearch { 169 | width: 100%; 170 | padding: 8px; 171 | border: 1px solid var(--border-color); 172 | border-radius: 4px; 173 | margin-bottom: 10px; 174 | } 175 | 176 | .tree-view { 177 | font-family: monospace; 178 | line-height: 1.5; 179 | } 180 | 181 | .tree-item { 182 | margin: 2px 0; 183 | cursor: pointer; 184 | } 185 | 186 | .tree-toggle { 187 | display: inline-block; 188 | width: 16px; 189 | text-align: center; 190 | user-select: none; 191 | } 192 | 193 | .tree-content { 194 | padding-left: 20px; 195 | display: none; 196 | } 197 | 198 | .tree-content.expanded { 199 | display: block; 200 | } 201 | 202 | .selected { 203 | background-color: #E3F2FD; 204 | border-radius: 3px; 205 | } 206 | 207 | #nodeDetails { 208 | margin-top: 20px; 209 | padding: 15px; 210 | background-color: #F5F5F5; 211 | border-radius: 4px; 212 | display: none; 213 | } 214 | 215 | .detail-grid { 216 | display: grid; 217 | grid-template-columns: 1fr 1fr; 218 | gap: 15px; 219 | } 220 | 221 | @media (max-width: 768px) { 222 | .detail-grid { 223 | grid-template-columns: 1fr; 224 | } 225 | } 226 | 227 | .detail-section { 228 | border: 1px solid var(--border-color); 229 | border-radius: 4px; 230 | padding: 10px; 231 | background-color: white; 232 | } 233 | 234 | .detail-title { 235 | font-weight: 500; 236 | margin-bottom: 10px; 237 | color: var(--primary-color); 238 | } 239 | 240 | .detail-content { 241 | max-height: 300px; 242 | overflow: auto; 243 | } 244 | 245 | .css-preview { 246 | border: 1px solid #ddd; 247 | padding: 15px; 248 | margin-top: 10px; 249 | border-radius: 4px; 250 | } 251 | </style> 252 | </head> 253 | 254 | <body> 255 | <div class="container"> 256 | <h1>Figma 节点 CSS 样式查看器</h1> 257 | 258 | <div class="info-box"> 259 | 此工具用于查看Figma节点数据及其CSS样式转换结果。您可以上传JSON文件或加载示例数据。 260 | </div> 261 | 262 | <div class="file-input-container"> 263 | <input type="file" id="fileInput" accept=".json"> 264 | <button id="loadFile">加载文件</button> 265 | <button id="loadSample">加载示例数据</button> 266 | </div> 267 | 268 | <div class="tabs"> 269 | <div class="tab active" data-tab="nodeTree">节点树</div> 270 | <div class="tab" data-tab="cssStyles">CSS 样式</div> 271 | </div> 272 | 273 | <div id="nodeTree" class="tab-content active"> 274 | <div class="search-container"> 275 | <input type="text" id="nodeSearch" placeholder="搜索节点名称..."> 276 | </div> 277 | <div class="tree-view" id="nodeTreeView"></div> 278 | <div id="nodeDetails"> 279 | <h3>节点详情</h3> 280 | <div class="detail-grid"> 281 | <div class="detail-section"> 282 | <div class="detail-title">基本信息</div> 283 | <div class="detail-content" id="nodeBasicInfo"></div> 284 | </div> 285 | <div class="detail-section"> 286 | <div class="detail-title">CSS 样式</div> 287 | <div class="detail-content" id="nodeCssStyles"></div> 288 | <div class="css-preview" id="cssPreview"></div> 289 | </div> 290 | </div> 291 | </div> 292 | </div> 293 | 294 | <div id="cssStyles" class="tab-content"> 295 | <div id="cssStylesContent" class="css-styles"></div> 296 | </div> 297 | </div> 298 | 299 | <script> 300 | let figmaData = null 301 | 302 | // DOM元素 303 | const fileInput = document.getElementById('fileInput') 304 | const loadFileBtn = document.getElementById('loadFile') 305 | const loadSampleBtn = document.getElementById('loadSample') 306 | const tabs = document.querySelectorAll('.tab') 307 | const tabContents = document.querySelectorAll('.tab-content') 308 | const nodeTreeView = document.getElementById('nodeTreeView') 309 | const nodeSearch = document.getElementById('nodeSearch') 310 | const nodeDetails = document.getElementById('nodeDetails') 311 | const nodeBasicInfo = document.getElementById('nodeBasicInfo') 312 | const nodeCssStyles = document.getElementById('nodeCssStyles') 313 | const cssPreview = document.getElementById('cssPreview') 314 | const cssStylesContent = document.getElementById('cssStylesContent') 315 | 316 | // 初始化 317 | document.addEventListener('DOMContentLoaded', () => { 318 | // 加载示例数据(如果在同目录下存在) 319 | try { 320 | fetch('./simplified-with-css.json') 321 | .then(response => { 322 | if (!response.ok) throw new Error('示例数据未找到') 323 | return response.json() 324 | }) 325 | .then(data => { 326 | figmaData = data 327 | renderData() 328 | }) 329 | .catch(err => console.log('未找到示例数据,请上传文件')) 330 | } catch (e) { 331 | console.log('未找到示例数据,请上传文件') 332 | } 333 | }) 334 | 335 | // 事件监听器 336 | loadFileBtn.addEventListener('click', () => { 337 | if (fileInput.files.length > 0) { 338 | const file = fileInput.files[0] 339 | const reader = new FileReader() 340 | 341 | reader.onload = (e) => { 342 | try { 343 | figmaData = JSON.parse(e.target.result) 344 | renderData() 345 | } catch (err) { 346 | alert('JSON解析错误: ' + err.message) 347 | } 348 | } 349 | 350 | reader.readAsText(file) 351 | } else { 352 | alert('请选择一个JSON文件') 353 | } 354 | }) 355 | 356 | loadSampleBtn.addEventListener('click', async () => { 357 | try { 358 | const response = await fetch('./simplified-with-css.json') 359 | if (!response.ok) throw new Error('示例数据未找到') 360 | figmaData = await response.json() 361 | renderData() 362 | } catch (err) { 363 | alert('加载示例数据失败: ' + err.message) 364 | } 365 | }) 366 | 367 | // 标签切换 368 | tabs.forEach(tab => { 369 | tab.addEventListener('click', () => { 370 | const tabId = tab.getAttribute('data-tab') 371 | 372 | tabs.forEach(t => t.classList.remove('active')) 373 | tabContents.forEach(tc => tc.classList.remove('active')) 374 | 375 | tab.classList.add('active') 376 | document.getElementById(tabId).classList.add('active') 377 | }) 378 | }) 379 | 380 | // 搜索功能 381 | nodeSearch.addEventListener('input', () => { 382 | const searchTerm = nodeSearch.value.toLowerCase() 383 | const treeItems = document.querySelectorAll('.tree-item') 384 | 385 | treeItems.forEach(item => { 386 | const text = item.textContent.toLowerCase() 387 | if (text.includes(searchTerm)) { 388 | item.style.display = 'block' 389 | 390 | // 展开父级 391 | let parent = item.parentElement 392 | while (parent && parent.classList.contains('tree-content')) { 393 | parent.classList.add('expanded') 394 | parent = parent.parentElement.parentElement 395 | } 396 | } else { 397 | item.style.display = 'none' 398 | } 399 | }) 400 | }) 401 | 402 | // 渲染数据 403 | function renderData() { 404 | if (!figmaData) return 405 | 406 | // 渲染节点树 407 | renderNodeTree() 408 | 409 | // 渲染CSS样式 410 | renderCssStyles() 411 | } 412 | 413 | // 渲染节点树 414 | function renderNodeTree() { 415 | nodeTreeView.innerHTML = '' 416 | 417 | if (figmaData.nodes && Array.isArray(figmaData.nodes)) { 418 | figmaData.nodes.forEach(node => { 419 | nodeTreeView.appendChild(createTreeItem(node)) 420 | }) 421 | } 422 | } 423 | 424 | // 创建树项 425 | function createTreeItem(node, level = 0) { 426 | const item = document.createElement('div') 427 | item.className = 'tree-item' 428 | item.dataset.nodeId = node.id || '' 429 | 430 | const hasChildren = node.children && node.children.length > 0 431 | 432 | const toggle = document.createElement('span') 433 | toggle.className = 'tree-toggle' 434 | toggle.textContent = hasChildren ? '▶' : ' ' 435 | 436 | const label = document.createElement('span') 437 | label.className = 'tree-label' 438 | label.textContent = `${node.name || 'Unnamed'} (${node.type || 'Unknown'})` 439 | 440 | item.appendChild(toggle) 441 | item.appendChild(label) 442 | 443 | if (hasChildren) { 444 | const content = document.createElement('div') 445 | content.className = 'tree-content' 446 | 447 | node.children.forEach(child => { 448 | content.appendChild(createTreeItem(child, level + 1)) 449 | }) 450 | 451 | toggle.addEventListener('click', () => { 452 | toggle.textContent = content.classList.toggle('expanded') ? '▼' : '▶' 453 | }) 454 | 455 | item.appendChild(content) 456 | } 457 | 458 | // 点击查看节点详情 459 | item.addEventListener('click', (e) => { 460 | if (e.target !== toggle) { 461 | document.querySelectorAll('.tree-item').forEach(i => i.classList.remove('selected')) 462 | item.classList.add('selected') 463 | showNodeDetails(node) 464 | } 465 | e.stopPropagation() 466 | }) 467 | 468 | return item 469 | } 470 | 471 | // 显示节点详情 472 | function showNodeDetails(node) { 473 | nodeDetails.style.display = 'block' 474 | 475 | // 基本信息 476 | nodeBasicInfo.innerHTML = ` 477 | <div><strong>ID:</strong> ${node.id || 'N/A'}</div> 478 | <div><strong>名称:</strong> ${node.name || 'Unnamed'}</div> 479 | <div><strong>类型:</strong> ${node.type || 'Unknown'}</div> 480 | ${node.boundingBox ? ` 481 | <div><strong>位置:</strong> X: ${node.boundingBox.x.toFixed(2)}, Y: ${node.boundingBox.y.toFixed(2)}</div> 482 | <div><strong>尺寸:</strong> W: ${node.boundingBox.width.toFixed(2)}, H: ${node.boundingBox.height.toFixed(2)}</div> 483 | ` : ''} 484 | ` 485 | 486 | // CSS样式 487 | if (node.cssStyles && Object.keys(node.cssStyles).length > 0) { 488 | let cssStylesHtml = '<div class="properties">' 489 | 490 | for (const [property, value] of Object.entries(node.cssStyles)) { 491 | cssStylesHtml += ` 492 | <div class="property"> 493 | ${property.includes('color') || property.includes('background') ? 494 | `<span class="color-box" style="background-color: ${value}"></span>` : ''} 495 | <strong>${property}:</strong> ${value} 496 | </div> 497 | ` 498 | } 499 | 500 | cssStylesHtml += '</div>' 501 | nodeCssStyles.innerHTML = cssStylesHtml 502 | 503 | // CSS预览 504 | let styles = '' 505 | for (const [property, value] of Object.entries(node.cssStyles)) { 506 | styles += `${property}: ${value};\n` 507 | } 508 | 509 | cssPreview.innerHTML = ` 510 | <div class="detail-title">预览</div> 511 | <div style="${styles} border: 1px dashed #ccc; min-height: 50px; display: flex; align-items: center; justify-content: center;"> 512 | ${node.type === 'TEXT' && node.characters ? node.characters : 'CSS样式预览'} 513 | </div> 514 | <pre style="margin-top: 10px;">${styles}</pre> 515 | ` 516 | cssPreview.style.display = 'block' 517 | } else { 518 | nodeCssStyles.innerHTML = '<div>该节点没有CSS样式</div>' 519 | cssPreview.style.display = 'none' 520 | } 521 | } 522 | 523 | // 渲染CSS样式 524 | function renderCssStyles() { 525 | cssStylesContent.innerHTML = '' 526 | 527 | if (!figmaData.nodes) return 528 | 529 | // 收集所有样式 530 | const stylesMap = new Map() 531 | 532 | function collectStyles(nodes) { 533 | if (!Array.isArray(nodes)) return 534 | 535 | nodes.forEach(node => { 536 | if (node.cssStyles && Object.keys(node.cssStyles).length > 0) { 537 | const styleKey = JSON.stringify(node.cssStyles) 538 | 539 | if (!stylesMap.has(styleKey)) { 540 | stylesMap.set(styleKey, { 541 | styles: node.cssStyles, 542 | count: 1, 543 | nodeName: node.name, 544 | nodeType: node.type 545 | }) 546 | } else { 547 | const info = stylesMap.get(styleKey) 548 | info.count++ 549 | } 550 | } 551 | 552 | if (node.children) { 553 | collectStyles(node.children) 554 | } 555 | }) 556 | } 557 | 558 | collectStyles(figmaData.nodes) 559 | 560 | // 按使用频率排序并仅显示前50个样式 561 | const sortedStyles = Array.from(stylesMap.entries()) 562 | .sort((a, b) => b[1].count - a[1].count) 563 | .slice(0, 50) 564 | 565 | // 创建样式卡片 566 | sortedStyles.forEach(([styleKey, info]) => { 567 | const { styles, count, nodeName, nodeType } = info 568 | 569 | const card = document.createElement('div') 570 | card.className = 'style-card' 571 | 572 | let preview = '' 573 | if (styles.backgroundColor) { 574 | preview = `background-color: ${styles.backgroundColor};` 575 | } else if (styles.color) { 576 | preview = `color: ${styles.color}; background-color: #f0f0f0;` 577 | } 578 | 579 | let stylesStr = '' 580 | for (const [property, value] of Object.entries(styles)) { 581 | stylesStr += `${property}: ${value};\n` 582 | } 583 | 584 | card.innerHTML = ` 585 | <div class="style-preview" style="${preview}"> 586 | <div style="${Object.entries(styles).map(([p, v]) => `${p}: ${v}`).join('; ')}"> 587 | ${nodeType === 'TEXT' ? '文本样式示例' : '样式预览'} 588 | </div> 589 | </div> 590 | <div class="style-info"> 591 | <div class="style-name">${nodeName || 'Unnamed'} (${nodeType || 'Unknown'}) - 使用 ${count} 次</div> 592 | <div class="style-properties"> 593 | ${Object.entries(styles).map(([property, value]) => ` 594 | <div class="property"> 595 | ${property.includes('color') || property.includes('background') ? 596 | `<span class="color-box" style="background-color: ${value}"></span>` : ''} 597 | <strong>${property}:</strong> ${value} 598 | </div> 599 | `).join('')} 600 | </div> 601 | </div> 602 | ` 603 | 604 | cssStylesContent.appendChild(card) 605 | }) 606 | } 607 | </script> 608 | </body> 609 | 610 | </html> 611 | ``` -------------------------------------------------------------------------------- /test/test-output/real-node-data.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "test", 3 | "lastModified": "2025-03-25T08:22:10Z", 4 | "thumbnailUrl": "https://s3-alpha.figma.com/thumbnails/473a648c-4039-40c2-8046-1e1a2030aa81?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAQ4GOSFWCT3MRUCDU%2F20250323%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20250323T120000Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=016073092878d75d2d8b7e2fc2425fd97579e7f97d0d33fd89cd770cd30cc65a", 5 | "version": "2199364251293339646", 6 | "role": "owner", 7 | "editorType": "figma", 8 | "linkAccess": "view", 9 | "nodes": { 10 | "409:3528": { 11 | "document": { 12 | "id": "409:3528", 13 | "name": "Frame", 14 | "type": "FRAME", 15 | "scrollBehavior": "SCROLLS", 16 | "children": [ 17 | { 18 | "id": "409:3529", 19 | "name": "Group", 20 | "type": "GROUP", 21 | "scrollBehavior": "SCROLLS", 22 | "children": [ 23 | { 24 | "id": "409:3530", 25 | "name": "Group", 26 | "type": "GROUP", 27 | "scrollBehavior": "SCROLLS", 28 | "children": [ 29 | { 30 | "id": "409:3531", 31 | "name": "Song Title - Singer", 32 | "type": "TEXT", 33 | "scrollBehavior": "SCROLLS", 34 | "blendMode": "PASS_THROUGH", 35 | "fills": [ 36 | { 37 | "blendMode": "NORMAL", 38 | "type": "SOLID", 39 | "color": { 40 | "r": 1, 41 | "g": 1, 42 | "b": 1, 43 | "a": 1 44 | } 45 | } 46 | ], 47 | "strokes": [], 48 | "strokeWeight": 1.6879849433898926, 49 | "strokeAlign": "INSIDE", 50 | "absoluteBoundingBox": { 51 | "x": 13392.404296875, 52 | "y": 13380.9150390625, 53 | "width": 106, 54 | "height": 14 55 | }, 56 | "absoluteRenderBounds": { 57 | "x": 13392.7978515625, 58 | "y": 13383.240234375, 59 | "width": 105.2763671875, 60 | "height": 10.93359375 61 | }, 62 | "constraints": { 63 | "vertical": "SCALE", 64 | "horizontal": "SCALE" 65 | }, 66 | "characters": "Song Title - Singer", 67 | "characterStyleOverrides": [], 68 | "styleOverrideTable": {}, 69 | "lineTypes": [ 70 | "NONE" 71 | ], 72 | "lineIndentations": [ 73 | 0 74 | ], 75 | "style": { 76 | "fontFamily": "Montserrat", 77 | "fontPostScriptName": "Montserrat-SemiBold", 78 | "fontStyle": "SemiBold", 79 | "fontWeight": 600, 80 | "textAutoResize": "WIDTH_AND_HEIGHT", 81 | "fontSize": 11.237085342407227, 82 | "textAlignHorizontal": "LEFT", 83 | "textAlignVertical": "TOP", 84 | "letterSpacing": 0, 85 | "lineHeightPx": 13.698006629943848, 86 | "lineHeightPercent": 100, 87 | "lineHeightUnit": "INTRINSIC_%" 88 | }, 89 | "layoutVersion": 4, 90 | "effects": [], 91 | "interactions": [] 92 | }, 93 | { 94 | "id": "409:3533", 95 | "name": "@LoremIpsum", 96 | "type": "TEXT", 97 | "scrollBehavior": "SCROLLS", 98 | "blendMode": "PASS_THROUGH", 99 | "fills": [ 100 | { 101 | "blendMode": "NORMAL", 102 | "type": "SOLID", 103 | "color": { 104 | "r": 1, 105 | "g": 1, 106 | "b": 1, 107 | "a": 1 108 | } 109 | } 110 | ], 111 | "strokes": [], 112 | "strokeWeight": 1.6879849433898926, 113 | "strokeAlign": "INSIDE", 114 | "absoluteBoundingBox": { 115 | "x": 13364.6875, 116 | "y": 13343.16796875, 117 | "width": 86, 118 | "height": 14 119 | }, 120 | "absoluteRenderBounds": { 121 | "x": 13365.181640625, 122 | "y": 13346.201171875, 123 | "width": 84.150390625, 124 | "height": 10.2587890625 125 | }, 126 | "constraints": { 127 | "vertical": "SCALE", 128 | "horizontal": "SCALE" 129 | }, 130 | "characters": "@LoremIpsum", 131 | "characterStyleOverrides": [], 132 | "styleOverrideTable": {}, 133 | "lineTypes": [ 134 | "NONE" 135 | ], 136 | "lineIndentations": [ 137 | 0 138 | ], 139 | "style": { 140 | "fontFamily": "Montserrat", 141 | "fontPostScriptName": "Montserrat-SemiBold", 142 | "fontStyle": "SemiBold", 143 | "fontWeight": 600, 144 | "textAutoResize": "WIDTH_AND_HEIGHT", 145 | "fontSize": 11.237085342407227, 146 | "textAlignHorizontal": "LEFT", 147 | "textAlignVertical": "TOP", 148 | "letterSpacing": 0, 149 | "lineHeightPx": 13.698006629943848, 150 | "lineHeightPercent": 100, 151 | "lineHeightUnit": "INTRINSIC_%" 152 | }, 153 | "layoutVersion": 4, 154 | "effects": [], 155 | "interactions": [] 156 | }, 157 | { 158 | "id": "409:3535", 159 | "name": "Share", 160 | "type": "TEXT", 161 | "scrollBehavior": "SCROLLS", 162 | "blendMode": "PASS_THROUGH", 163 | "fills": [ 164 | { 165 | "blendMode": "NORMAL", 166 | "type": "SOLID", 167 | "color": { 168 | "r": 1, 169 | "g": 1, 170 | "b": 1, 171 | "a": 1 172 | } 173 | } 174 | ], 175 | "strokes": [], 176 | "strokeWeight": 1.6879849433898926, 177 | "strokeAlign": "INSIDE", 178 | "absoluteBoundingBox": { 179 | "x": 13603.939453125, 180 | "y": 13326.283203125, 181 | "width": 27, 182 | "height": 11 183 | }, 184 | "absoluteRenderBounds": { 185 | "x": 13604.2509765625, 186 | "y": 13328.6796875, 187 | "width": 25.6318359375, 188 | "height": 6.6923828125 189 | }, 190 | "constraints": { 191 | "vertical": "SCALE", 192 | "horizontal": "SCALE" 193 | }, 194 | "characters": "Share", 195 | "characterStyleOverrides": [], 196 | "styleOverrideTable": {}, 197 | "lineTypes": [ 198 | "NONE" 199 | ], 200 | "lineIndentations": [ 201 | 0 202 | ], 203 | "style": { 204 | "fontFamily": "Montserrat", 205 | "fontPostScriptName": "Montserrat-SemiBold", 206 | "fontStyle": "SemiBold", 207 | "fontWeight": 600, 208 | "textAutoResize": "WIDTH_AND_HEIGHT", 209 | "fontSize": 8.899900436401367, 210 | "textAlignHorizontal": "LEFT", 211 | "textAlignVertical": "TOP", 212 | "letterSpacing": 0, 213 | "lineHeightPx": 10.848978042602539, 214 | "lineHeightPercent": 100, 215 | "lineHeightUnit": "INTRINSIC_%" 216 | }, 217 | "layoutVersion": 4, 218 | "effects": [], 219 | "interactions": [] 220 | }, 221 | { 222 | "id": "409:3537", 223 | "name": "400", 224 | "type": "TEXT", 225 | "scrollBehavior": "SCROLLS", 226 | "blendMode": "PASS_THROUGH", 227 | "fills": [ 228 | { 229 | "blendMode": "NORMAL", 230 | "type": "SOLID", 231 | "color": { 232 | "r": 1, 233 | "g": 1, 234 | "b": 1, 235 | "a": 1 236 | } 237 | } 238 | ], 239 | "strokes": [], 240 | "strokeWeight": 1.6879849433898926, 241 | "strokeAlign": "INSIDE", 242 | "absoluteBoundingBox": { 243 | "x": 13608.59375, 244 | "y": 13274.6572265625, 245 | "width": 19, 246 | "height": 11 247 | }, 248 | "absoluteRenderBounds": { 249 | "x": 13608.896484375, 250 | "y": 13277.337890625, 251 | "width": 17.32421875, 252 | "height": 6.408203125 253 | }, 254 | "constraints": { 255 | "vertical": "SCALE", 256 | "horizontal": "SCALE" 257 | }, 258 | "characters": "400", 259 | "characterStyleOverrides": [], 260 | "styleOverrideTable": {}, 261 | "lineTypes": [ 262 | "NONE" 263 | ], 264 | "lineIndentations": [ 265 | 0 266 | ], 267 | "style": { 268 | "fontFamily": "Montserrat", 269 | "fontPostScriptName": "Montserrat-SemiBold", 270 | "fontStyle": "SemiBold", 271 | "fontWeight": 600, 272 | "textAutoResize": "WIDTH_AND_HEIGHT", 273 | "fontSize": 8.899900436401367, 274 | "textAlignHorizontal": "LEFT", 275 | "textAlignVertical": "TOP", 276 | "letterSpacing": 0, 277 | "lineHeightPx": 10.848978042602539, 278 | "lineHeightPercent": 100, 279 | "lineHeightUnit": "INTRINSIC_%" 280 | }, 281 | "layoutVersion": 4, 282 | "effects": [], 283 | "interactions": [] 284 | }, 285 | { 286 | "id": "409:3539", 287 | "name": "500 K", 288 | "type": "TEXT", 289 | "scrollBehavior": "SCROLLS", 290 | "blendMode": "PASS_THROUGH", 291 | "fills": [ 292 | { 293 | "blendMode": "NORMAL", 294 | "type": "SOLID", 295 | "color": { 296 | "r": 1, 297 | "g": 1, 298 | "b": 1, 299 | "a": 1 300 | } 301 | } 302 | ], 303 | "strokes": [], 304 | "strokeWeight": 1.6879849433898926, 305 | "strokeAlign": "INSIDE", 306 | "absoluteBoundingBox": { 307 | "x": 13603.939453125, 308 | "y": 13217.1142578125, 309 | "width": 27, 310 | "height": 11 311 | }, 312 | "absoluteRenderBounds": { 313 | "x": 13604.0546875, 314 | "y": 13219.794921875, 315 | "width": 26.05078125, 316 | "height": 6.408203125 317 | }, 318 | "constraints": { 319 | "vertical": "SCALE", 320 | "horizontal": "SCALE" 321 | }, 322 | "characters": "500 K", 323 | "characterStyleOverrides": [], 324 | "styleOverrideTable": {}, 325 | "lineTypes": [ 326 | "NONE" 327 | ], 328 | "lineIndentations": [ 329 | 0 330 | ], 331 | "style": { 332 | "fontFamily": "Montserrat", 333 | "fontPostScriptName": "Montserrat-SemiBold", 334 | "fontStyle": "SemiBold", 335 | "fontWeight": 600, 336 | "textAutoResize": "WIDTH_AND_HEIGHT", 337 | "fontSize": 8.899900436401367, 338 | "textAlignHorizontal": "LEFT", 339 | "textAlignVertical": "TOP", 340 | "letterSpacing": 0, 341 | "lineHeightPx": 10.848978042602539, 342 | "lineHeightPercent": 100, 343 | "lineHeightUnit": "INTRINSIC_%" 344 | }, 345 | "layoutVersion": 4, 346 | "effects": [], 347 | "interactions": [] 348 | }, 349 | { 350 | "id": "409:3541", 351 | "name": "#lorem#Ipsum#loremipsum", 352 | "type": "TEXT", 353 | "scrollBehavior": "SCROLLS", 354 | "blendMode": "PASS_THROUGH", 355 | "fills": [ 356 | { 357 | "blendMode": "NORMAL", 358 | "type": "SOLID", 359 | "color": { 360 | "r": 1, 361 | "g": 1, 362 | "b": 1, 363 | "a": 1 364 | } 365 | } 366 | ], 367 | "strokes": [], 368 | "strokeWeight": 1.6879849433898926, 369 | "strokeAlign": "INSIDE", 370 | "absoluteBoundingBox": { 371 | "x": 13364.6875, 372 | "y": 13357.8525390625, 373 | "width": 165, 374 | "height": 14 375 | }, 376 | "absoluteRenderBounds": { 377 | "x": 13364.9462890625, 378 | "y": 13360.177734375, 379 | "width": 163.4404296875, 380 | "height": 10.8544921875 381 | }, 382 | "constraints": { 383 | "vertical": "SCALE", 384 | "horizontal": "SCALE" 385 | }, 386 | "characters": "#lorem#Ipsum#loremipsum", 387 | "characterStyleOverrides": [], 388 | "styleOverrideTable": {}, 389 | "lineTypes": [ 390 | "NONE" 391 | ], 392 | "lineIndentations": [ 393 | 0 394 | ], 395 | "style": { 396 | "fontFamily": "Montserrat", 397 | "fontPostScriptName": "Montserrat-SemiBold", 398 | "fontStyle": "SemiBold", 399 | "fontWeight": 600, 400 | "textAutoResize": "WIDTH_AND_HEIGHT", 401 | "fontSize": 11.237085342407227, 402 | "textAlignHorizontal": "LEFT", 403 | "textAlignVertical": "TOP", 404 | "letterSpacing": 0, 405 | "lineHeightPx": 13.698006629943848, 406 | "lineHeightPercent": 100, 407 | "lineHeightUnit": "INTRINSIC_%" 408 | }, 409 | "layoutVersion": 4, 410 | "effects": [], 411 | "interactions": [] 412 | }, 413 | { 414 | "id": "409:3542", 415 | "name": "Group", 416 | "type": "GROUP", 417 | "scrollBehavior": "SCROLLS", 418 | "children": [ 419 | { 420 | "id": "409:3543", 421 | "name": "Vector", 422 | "type": "VECTOR", 423 | "scrollBehavior": "SCROLLS", 424 | "blendMode": "PASS_THROUGH", 425 | "fills": [ 426 | { 427 | "blendMode": "NORMAL", 428 | "type": "SOLID", 429 | "color": { 430 | "r": 0.13725490868091583, 431 | "g": 0.12156862765550613, 432 | "b": 0.125490203499794, 433 | "a": 1 434 | } 435 | } 436 | ], 437 | "strokes": [], 438 | "strokeWeight": 1.6879849433898926, 439 | "strokeAlign": "INSIDE", 440 | "absoluteBoundingBox": { 441 | "x": 13599.95703125, 442 | "y": 13356.5888671875, 443 | "width": 34.249202728271484, 444 | "height": 34.249176025390625 445 | }, 446 | "absoluteRenderBounds": { 447 | "x": 13599.95703125, 448 | "y": 13356.5888671875, 449 | "width": 34.2490234375, 450 | "height": 34.2490234375 451 | }, 452 | "constraints": { 453 | "vertical": "SCALE", 454 | "horizontal": "SCALE" 455 | }, 456 | "effects": [], 457 | "interactions": [] 458 | }, 459 | { 460 | "id": "409:3544", 461 | "name": "Vector", 462 | "type": "VECTOR", 463 | "scrollBehavior": "SCROLLS", 464 | "blendMode": "PASS_THROUGH", 465 | "fills": [ 466 | { 467 | "blendMode": "NORMAL", 468 | "type": "SOLID", 469 | "color": { 470 | "r": 0.2549019753932953, 471 | "g": 0.250980406999588, 472 | "b": 0.25882354378700256, 473 | "a": 1 474 | } 475 | } 476 | ], 477 | "strokes": [], 478 | "strokeWeight": 1.6879849433898926, 479 | "strokeAlign": "INSIDE", 480 | "absoluteBoundingBox": { 481 | "x": 13617.07421875, 482 | "y": 13362.8515625, 483 | "width": 17.133037567138672, 484 | "height": 21.707462310791016 485 | }, 486 | "absoluteRenderBounds": { 487 | "x": 13617.07421875, 488 | "y": 13362.8515625, 489 | "width": 17.1328125, 490 | "height": 21.70703125 491 | }, 492 | "constraints": { 493 | "vertical": "SCALE", 494 | "horizontal": "SCALE" 495 | }, 496 | "effects": [], 497 | "interactions": [] 498 | }, 499 | { 500 | "id": "409:3545", 501 | "name": "Vector", 502 | "type": "VECTOR", 503 | "scrollBehavior": "SCROLLS", 504 | "blendMode": "PASS_THROUGH", 505 | "fills": [ 506 | { 507 | "blendMode": "NORMAL", 508 | "type": "SOLID", 509 | "color": { 510 | "r": 0.2549019753932953, 511 | "g": 0.250980406999588, 512 | "b": 0.25882354378700256, 513 | "a": 1 514 | } 515 | } 516 | ], 517 | "strokes": [], 518 | "strokeWeight": 1.6879849433898926, 519 | "strokeAlign": "INSIDE", 520 | "absoluteBoundingBox": { 521 | "x": 13599.95703125, 522 | "y": 13362.8515625, 523 | "width": 17.116167068481445, 524 | "height": 21.72435760498047 525 | }, 526 | "absoluteRenderBounds": { 527 | "x": 13599.95703125, 528 | "y": 13362.8515625, 529 | "width": 17.1162109375, 530 | "height": 21.724609375 531 | }, 532 | "constraints": { 533 | "vertical": "SCALE", 534 | "horizontal": "SCALE" 535 | }, 536 | "effects": [], 537 | "interactions": [] 538 | }, 539 | { 540 | "id": "409:3546", 541 | "name": "Vector", 542 | "type": "VECTOR", 543 | "scrollBehavior": "SCROLLS", 544 | "blendMode": "PASS_THROUGH", 545 | "fills": [ 546 | { 547 | "blendMode": "NORMAL", 548 | "type": "SOLID", 549 | "color": { 550 | "r": 0, 551 | "g": 0, 552 | "b": 0, 553 | "a": 1 554 | } 555 | } 556 | ], 557 | "strokes": [], 558 | "strokeWeight": 1.6879849433898926, 559 | "strokeAlign": "INSIDE", 560 | "absoluteBoundingBox": { 561 | "x": 13608.59765625, 562 | "y": 13365.248046875, 563 | "width": 16.93048667907715, 564 | "height": 16.93048667907715 565 | }, 566 | "absoluteRenderBounds": { 567 | "x": 13608.59765625, 568 | "y": 13365.248046875, 569 | "width": 16.9306640625, 570 | "height": 16.9306640625 571 | }, 572 | "constraints": { 573 | "vertical": "SCALE", 574 | "horizontal": "SCALE" 575 | }, 576 | "effects": [], 577 | "interactions": [] 578 | }, 579 | { 580 | "id": "409:3547", 581 | "name": "Vector", 582 | "type": "VECTOR", 583 | "scrollBehavior": "SCROLLS", 584 | "blendMode": "PASS_THROUGH", 585 | "fills": [ 586 | { 587 | "blendMode": "NORMAL", 588 | "type": "SOLID", 589 | "color": { 590 | "r": 1, 591 | "g": 1, 592 | "b": 1, 593 | "a": 1 594 | } 595 | } 596 | ], 597 | "strokes": [], 598 | "strokeWeight": 1.6879849433898926, 599 | "strokeAlign": "INSIDE", 600 | "absoluteBoundingBox": { 601 | "x": 13610.490234375, 602 | "y": 13374.26171875, 603 | "width": 13.166287422180176, 604 | "height": 7.933498382568359 605 | }, 606 | "absoluteRenderBounds": { 607 | "x": 13610.490234375, 608 | "y": 13374.26171875, 609 | "width": 13.166015625, 610 | "height": 7.93359375 611 | }, 612 | "constraints": { 613 | "vertical": "SCALE", 614 | "horizontal": "SCALE" 615 | }, 616 | "effects": [], 617 | "interactions": [] 618 | }, 619 | { 620 | "id": "409:3548", 621 | "name": "Vector", 622 | "type": "VECTOR", 623 | "scrollBehavior": "SCROLLS", 624 | "blendMode": "PASS_THROUGH", 625 | "fills": [ 626 | { 627 | "blendMode": "NORMAL", 628 | "type": "SOLID", 629 | "color": { 630 | "r": 1, 631 | "g": 1, 632 | "b": 1, 633 | "a": 1 634 | } 635 | } 636 | ], 637 | "strokes": [], 638 | "strokeWeight": 1.6879849433898926, 639 | "strokeAlign": "INSIDE", 640 | "absoluteBoundingBox": { 641 | "x": 13613.81640625, 642 | "y": 13366.7333984375, 643 | "width": 6.515623092651367, 644 | "height": 6.51564884185791 645 | }, 646 | "absoluteRenderBounds": { 647 | "x": 13613.81640625, 648 | "y": 13366.7333984375, 649 | "width": 6.515625, 650 | "height": 6.515625 651 | }, 652 | "constraints": { 653 | "vertical": "SCALE", 654 | "horizontal": "SCALE" 655 | }, 656 | "effects": [], 657 | "interactions": [] 658 | } 659 | ], 660 | "blendMode": "PASS_THROUGH", 661 | "clipsContent": false, 662 | "background": [], 663 | "fills": [], 664 | "strokes": [], 665 | "strokeWeight": 1.6879849433898926, 666 | "strokeAlign": "INSIDE", 667 | "backgroundColor": { 668 | "r": 0, 669 | "g": 0, 670 | "b": 0, 671 | "a": 0 672 | }, 673 | "absoluteBoundingBox": { 674 | "x": 13599.95703125, 675 | "y": 13356.5888671875, 676 | "width": 34.25025939941406, 677 | "height": 34.249176025390625 678 | }, 679 | "absoluteRenderBounds": { 680 | "x": 13599.95703125, 681 | "y": 13356.5888671875, 682 | "width": 34.25025939941406, 683 | "height": 34.249176025390625 684 | }, 685 | "constraints": { 686 | "vertical": "SCALE", 687 | "horizontal": "SCALE" 688 | }, 689 | "effects": [], 690 | "interactions": [] 691 | }, 692 | { 693 | "id": "409:3549", 694 | "name": "Vector", 695 | "type": "VECTOR", 696 | "scrollBehavior": "SCROLLS", 697 | "blendMode": "PASS_THROUGH", 698 | "fills": [ 699 | { 700 | "blendMode": "NORMAL", 701 | "type": "SOLID", 702 | "color": { 703 | "r": 1, 704 | "g": 1, 705 | "b": 1, 706 | "a": 1 707 | } 708 | } 709 | ], 710 | "strokes": [], 711 | "strokeWeight": 1.6879849433898926, 712 | "strokeAlign": "INSIDE", 713 | "absoluteBoundingBox": { 714 | "x": 13604.56640625, 715 | "y": 13295.3486328125, 716 | "width": 25.04969024658203, 717 | "height": 25.30290412902832 718 | }, 719 | "absoluteRenderBounds": { 720 | "x": 13604.56640625, 721 | "y": 13295.3486328125, 722 | "width": 25.0498046875, 723 | "height": 25.302734375 724 | }, 725 | "constraints": { 726 | "vertical": "SCALE", 727 | "horizontal": "SCALE" 728 | }, 729 | "exportSettings": [ 730 | { 731 | "suffix": "", 732 | "format": "SVG", 733 | "constraint": { 734 | "type": "SCALE", 735 | "value": 1 736 | } 737 | } 738 | ], 739 | "effects": [], 740 | "interactions": [] 741 | }, 742 | { 743 | "id": "409:3550", 744 | "name": "Group", 745 | "type": "GROUP", 746 | "scrollBehavior": "SCROLLS", 747 | "children": [ 748 | { 749 | "id": "409:3551", 750 | "name": "Vector", 751 | "type": "VECTOR", 752 | "scrollBehavior": "SCROLLS", 753 | "blendMode": "PASS_THROUGH", 754 | "fills": [ 755 | { 756 | "blendMode": "NORMAL", 757 | "type": "SOLID", 758 | "color": { 759 | "r": 1, 760 | "g": 1, 761 | "b": 1, 762 | "a": 1 763 | } 764 | } 765 | ], 766 | "strokes": [], 767 | "strokeWeight": 1.6879849433898926, 768 | "strokeAlign": "INSIDE", 769 | "absoluteBoundingBox": { 770 | "x": 13602.7265625, 771 | "y": 13239.4931640625, 772 | "width": 28.695743560791016, 773 | "height": 29.134611129760742 774 | }, 775 | "absoluteRenderBounds": { 776 | "x": 13602.7265625, 777 | "y": 13239.4931640625, 778 | "width": 28.6953125, 779 | "height": 29.134765625 780 | }, 781 | "constraints": { 782 | "vertical": "SCALE", 783 | "horizontal": "SCALE" 784 | }, 785 | "effects": [], 786 | "interactions": [] 787 | }, 788 | { 789 | "id": "409:3552", 790 | "name": "Vector", 791 | "type": "VECTOR", 792 | "scrollBehavior": "SCROLLS", 793 | "blendMode": "PASS_THROUGH", 794 | "fills": [ 795 | { 796 | "blendMode": "NORMAL", 797 | "type": "SOLID", 798 | "color": { 799 | "r": 0, 800 | "g": 0, 801 | "b": 0, 802 | "a": 1 803 | } 804 | } 805 | ], 806 | "strokes": [], 807 | "strokeWeight": 1.6879849433898926, 808 | "strokeAlign": "INSIDE", 809 | "absoluteBoundingBox": { 810 | "x": 13608.212890625, 811 | "y": 13249.384765625, 812 | "width": 3.949878692626953, 813 | "height": 3.949878692626953 814 | }, 815 | "absoluteRenderBounds": { 816 | "x": 13608.212890625, 817 | "y": 13249.384765625, 818 | "width": 3.9501953125, 819 | "height": 3.9501953125 820 | }, 821 | "constraints": { 822 | "vertical": "SCALE", 823 | "horizontal": "SCALE" 824 | }, 825 | "effects": [], 826 | "interactions": [] 827 | }, 828 | { 829 | "id": "409:3553", 830 | "name": "Vector", 831 | "type": "VECTOR", 832 | "scrollBehavior": "SCROLLS", 833 | "blendMode": "PASS_THROUGH", 834 | "fills": [ 835 | { 836 | "blendMode": "NORMAL", 837 | "type": "SOLID", 838 | "color": { 839 | "r": 0, 840 | "g": 0, 841 | "b": 0, 842 | "a": 1 843 | } 844 | } 845 | ], 846 | "strokes": [], 847 | "strokeWeight": 1.6879849433898926, 848 | "strokeAlign": "INSIDE", 849 | "absoluteBoundingBox": { 850 | "x": 13615.099609375, 851 | "y": 13249.384765625, 852 | "width": 3.949878692626953, 853 | "height": 3.949878692626953 854 | }, 855 | "absoluteRenderBounds": { 856 | "x": 13615.099609375, 857 | "y": 13249.384765625, 858 | "width": 3.9501953125, 859 | "height": 3.9501953125 860 | }, 861 | "constraints": { 862 | "vertical": "SCALE", 863 | "horizontal": "SCALE" 864 | }, 865 | "effects": [], 866 | "interactions": [] 867 | }, 868 | { 869 | "id": "409:3554", 870 | "name": "Vector", 871 | "type": "VECTOR", 872 | "scrollBehavior": "SCROLLS", 873 | "blendMode": "PASS_THROUGH", 874 | "fills": [ 875 | { 876 | "blendMode": "NORMAL", 877 | "type": "SOLID", 878 | "color": { 879 | "r": 0, 880 | "g": 0, 881 | "b": 0, 882 | "a": 1 883 | } 884 | } 885 | ], 886 | "strokes": [], 887 | "strokeWeight": 1.6879849433898926, 888 | "strokeAlign": "INSIDE", 889 | "absoluteBoundingBox": { 890 | "x": 13622.00390625, 891 | "y": 13249.384765625, 892 | "width": 3.949878692626953, 893 | "height": 3.949878692626953 894 | }, 895 | "absoluteRenderBounds": { 896 | "x": 13622.00390625, 897 | "y": 13249.384765625, 898 | "width": 3.9501953125, 899 | "height": 3.9501953125 900 | }, 901 | "constraints": { 902 | "vertical": "SCALE", 903 | "horizontal": "SCALE" 904 | }, 905 | "effects": [], 906 | "interactions": [] 907 | } 908 | ], 909 | "blendMode": "PASS_THROUGH", 910 | "clipsContent": false, 911 | "background": [], 912 | "fills": [], 913 | "strokes": [], 914 | "strokeWeight": 1.6879849433898926, 915 | "strokeAlign": "INSIDE", 916 | "backgroundColor": { 917 | "r": 0, 918 | "g": 0, 919 | "b": 0, 920 | "a": 0 921 | }, 922 | "absoluteBoundingBox": { 923 | "x": 13602.7265625, 924 | "y": 13239.4931640625, 925 | "width": 28.695743560791016, 926 | "height": 29.134611129760742 927 | }, 928 | "absoluteRenderBounds": { 929 | "x": 13602.7265625, 930 | "y": 13239.4931640625, 931 | "width": 28.695743560791016, 932 | "height": 29.134765625 933 | }, 934 | "constraints": { 935 | "vertical": "SCALE", 936 | "horizontal": "SCALE" 937 | }, 938 | "exportSettings": [ 939 | { 940 | "suffix": "", 941 | "format": "SVG", 942 | "constraint": { 943 | "type": "SCALE", 944 | "value": 1 945 | } 946 | } 947 | ], 948 | "effects": [], 949 | "interactions": [] 950 | }, 951 | { 952 | "id": "409:3555", 953 | "name": "Vector", 954 | "type": "VECTOR", 955 | "scrollBehavior": "SCROLLS", 956 | "blendMode": "PASS_THROUGH", 957 | "fills": [ 958 | { 959 | "blendMode": "NORMAL", 960 | "type": "SOLID", 961 | "color": { 962 | "r": 1, 963 | "g": 1, 964 | "b": 1, 965 | "a": 1 966 | } 967 | } 968 | ], 969 | "strokes": [], 970 | "strokeWeight": 1.6879849433898926, 971 | "strokeAlign": "INSIDE", 972 | "absoluteBoundingBox": { 973 | "x": 13602.67578125, 974 | "y": 13185.7646484375, 975 | "width": 28.830785751342773, 976 | "height": 24.948415756225586 977 | }, 978 | "absoluteRenderBounds": { 979 | "x": 13602.67578125, 980 | "y": 13185.7646484375, 981 | "width": 28.8310546875, 982 | "height": 24.9482421875 983 | }, 984 | "constraints": { 985 | "vertical": "SCALE", 986 | "horizontal": "SCALE" 987 | }, 988 | "exportSettings": [ 989 | { 990 | "suffix": "", 991 | "format": "SVG", 992 | "constraint": { 993 | "type": "SCALE", 994 | "value": 1 995 | } 996 | } 997 | ], 998 | "effects": [], 999 | "interactions": [] 1000 | }, 1001 | { 1002 | "id": "409:3565", 1003 | "name": "Group 1410104421", 1004 | "type": "GROUP", 1005 | "scrollBehavior": "SCROLLS", 1006 | "children": [ 1007 | { 1008 | "id": "409:3556", 1009 | "name": "Vector", 1010 | "type": "VECTOR", 1011 | "scrollBehavior": "SCROLLS", 1012 | "rotation": -0.7853981633974483, 1013 | "blendMode": "PASS_THROUGH", 1014 | "fills": [ 1015 | { 1016 | "blendMode": "NORMAL", 1017 | "type": "SOLID", 1018 | "color": { 1019 | "r": 0.9686274528503418, 1020 | "g": 0.12941177189350128, 1021 | "b": 0.30588236451148987, 1022 | "a": 1 1023 | } 1024 | } 1025 | ], 1026 | "strokes": [], 1027 | "strokeWeight": 1.6879849433898926, 1028 | "strokeAlign": "INSIDE", 1029 | "absoluteBoundingBox": { 1030 | "x": 13593.53125, 1031 | "y": 13119.377757252198, 1032 | "width": 47.07456362060657, 1033 | "height": 47.07456362060475 1034 | }, 1035 | "absoluteRenderBounds": { 1036 | "x": 13600.4248046875, 1037 | "y": 13126.271484375, 1038 | "width": 33.287109375, 1039 | "height": 33.287109375 1040 | }, 1041 | "constraints": { 1042 | "vertical": "SCALE", 1043 | "horizontal": "SCALE" 1044 | }, 1045 | "effects": [], 1046 | "interactions": [] 1047 | }, 1048 | { 1049 | "id": "409:3557", 1050 | "name": "Vector", 1051 | "type": "VECTOR", 1052 | "scrollBehavior": "SCROLLS", 1053 | "blendMode": "PASS_THROUGH", 1054 | "fills": [ 1055 | { 1056 | "blendMode": "NORMAL", 1057 | "type": "SOLID", 1058 | "color": { 1059 | "r": 1, 1060 | "g": 1, 1061 | "b": 1, 1062 | "a": 1 1063 | } 1064 | } 1065 | ], 1066 | "strokes": [], 1067 | "strokeWeight": 1.6879849433898926, 1068 | "strokeAlign": "INSIDE", 1069 | "absoluteBoundingBox": { 1070 | "x": 13607.166015625, 1071 | "y": 13133.015625, 1072 | "width": 19.816926956176758, 1073 | "height": 19.816951751708984 1074 | }, 1075 | "absoluteRenderBounds": { 1076 | "x": 13607.166015625, 1077 | "y": 13133.015625, 1078 | "width": 19.8173828125, 1079 | "height": 19.8173828125 1080 | }, 1081 | "constraints": { 1082 | "vertical": "SCALE", 1083 | "horizontal": "SCALE" 1084 | }, 1085 | "effects": [], 1086 | "interactions": [] 1087 | } 1088 | ], 1089 | "blendMode": "PASS_THROUGH", 1090 | "clipsContent": false, 1091 | "background": [], 1092 | "fills": [], 1093 | "strokes": [], 1094 | "strokeWeight": 1, 1095 | "strokeAlign": "INSIDE", 1096 | "backgroundColor": { 1097 | "r": 0, 1098 | "g": 0, 1099 | "b": 0, 1100 | "a": 0 1101 | }, 1102 | "absoluteBoundingBox": { 1103 | "x": 13593.53125, 1104 | "y": 13119.3779296875, 1105 | "width": 47.074562072753906, 1106 | "height": 47.074562072753906 1107 | }, 1108 | "absoluteRenderBounds": { 1109 | "x": 13593.53125, 1110 | "y": 13119.3779296875, 1111 | "width": 47.074562072753906, 1112 | "height": 47.074562072753906 1113 | }, 1114 | "constraints": { 1115 | "vertical": "TOP", 1116 | "horizontal": "LEFT" 1117 | }, 1118 | "exportSettings": [ 1119 | { 1120 | "suffix": "", 1121 | "format": "SVG", 1122 | "constraint": { 1123 | "type": "SCALE", 1124 | "value": 1 1125 | } 1126 | } 1127 | ], 1128 | "effects": [], 1129 | "interactions": [] 1130 | }, 1131 | { 1132 | "id": "409:3558", 1133 | "name": "Vector", 1134 | "type": "VECTOR", 1135 | "scrollBehavior": "SCROLLS", 1136 | "blendMode": "PASS_THROUGH", 1137 | "fills": [ 1138 | { 1139 | "blendMode": "NORMAL", 1140 | "type": "SOLID", 1141 | "color": { 1142 | "r": 1, 1143 | "g": 1, 1144 | "b": 1, 1145 | "a": 1 1146 | } 1147 | } 1148 | ], 1149 | "strokes": [], 1150 | "strokeWeight": 1.6879849433898926, 1151 | "strokeAlign": "INSIDE", 1152 | "absoluteBoundingBox": { 1153 | "x": 13369.177734375, 1154 | "y": 13381.9423828125, 1155 | "width": 12.102851867675781, 1156 | "height": 12.069082260131836 1157 | }, 1158 | "absoluteRenderBounds": { 1159 | "x": 13369.177734375, 1160 | "y": 13381.9423828125, 1161 | "width": 12.1025390625, 1162 | "height": 12.0693359375 1163 | }, 1164 | "constraints": { 1165 | "vertical": "SCALE", 1166 | "horizontal": "SCALE" 1167 | }, 1168 | "exportSettings": [ 1169 | { 1170 | "suffix": "", 1171 | "format": "SVG", 1172 | "constraint": { 1173 | "type": "SCALE", 1174 | "value": 1 1175 | } 1176 | } 1177 | ], 1178 | "effects": [], 1179 | "interactions": [] 1180 | }, 1181 | { 1182 | "id": "409:3562", 1183 | "name": "Vector", 1184 | "type": "VECTOR", 1185 | "scrollBehavior": "SCROLLS", 1186 | "blendMode": "PASS_THROUGH", 1187 | "fills": [ 1188 | { 1189 | "blendMode": "NORMAL", 1190 | "type": "SOLID", 1191 | "color": { 1192 | "r": 0.9686274528503418, 1193 | "g": 0.12941177189350128, 1194 | "b": 0.30588236451148987, 1195 | "a": 1 1196 | } 1197 | } 1198 | ], 1199 | "strokes": [], 1200 | "strokeWeight": 1.6879849433898926, 1201 | "strokeAlign": "INSIDE", 1202 | "absoluteBoundingBox": { 1203 | "x": 13366.86328125, 1204 | "y": 13324.078125, 1205 | "width": 32.74707794189453, 1206 | "height": 14.19596004486084 1207 | }, 1208 | "absoluteRenderBounds": { 1209 | "x": 13366.86328125, 1210 | "y": 13324.078125, 1211 | "width": 32.7470703125, 1212 | "height": 14.1962890625 1213 | }, 1214 | "constraints": { 1215 | "vertical": "SCALE", 1216 | "horizontal": "SCALE" 1217 | }, 1218 | "effects": [], 1219 | "interactions": [] 1220 | }, 1221 | { 1222 | "id": "409:3563", 1223 | "name": "HOT", 1224 | "type": "TEXT", 1225 | "scrollBehavior": "SCROLLS", 1226 | "blendMode": "PASS_THROUGH", 1227 | "fills": [ 1228 | { 1229 | "blendMode": "NORMAL", 1230 | "type": "SOLID", 1231 | "color": { 1232 | "r": 1, 1233 | "g": 1, 1234 | "b": 1, 1235 | "a": 1 1236 | } 1237 | } 1238 | ], 1239 | "strokes": [], 1240 | "strokeWeight": 1.6879849433898926, 1241 | "strokeAlign": "INSIDE", 1242 | "absoluteBoundingBox": { 1243 | "x": 13372.3125, 1244 | "y": 13325.1328125, 1245 | "width": 21, 1246 | "height": 11 1247 | }, 1248 | "absoluteRenderBounds": { 1249 | "x": 13372.9296875, 1250 | "y": 13327.8330078125, 1251 | "width": 19.435546875, 1252 | "height": 6.4228515625 1253 | }, 1254 | "constraints": { 1255 | "vertical": "SCALE", 1256 | "horizontal": "SCALE" 1257 | }, 1258 | "characters": "HOT", 1259 | "characterStyleOverrides": [], 1260 | "styleOverrideTable": {}, 1261 | "lineTypes": [ 1262 | "NONE" 1263 | ], 1264 | "lineIndentations": [ 1265 | 0 1266 | ], 1267 | "style": { 1268 | "fontFamily": "Montserrat", 1269 | "fontPostScriptName": "Montserrat-ExtraBold", 1270 | "fontStyle": "ExtraBold", 1271 | "fontWeight": 800, 1272 | "textAutoResize": "WIDTH_AND_HEIGHT", 1273 | "fontSize": 8.823772430419922, 1274 | "textAlignHorizontal": "LEFT", 1275 | "textAlignVertical": "TOP", 1276 | "letterSpacing": 0, 1277 | "lineHeightPx": 10.756178855895996, 1278 | "lineHeightPercent": 100, 1279 | "lineHeightUnit": "INTRINSIC_%" 1280 | }, 1281 | "layoutVersion": 4, 1282 | "effects": [], 1283 | "interactions": [] 1284 | } 1285 | ], 1286 | "blendMode": "PASS_THROUGH", 1287 | "clipsContent": false, 1288 | "background": [], 1289 | "fills": [], 1290 | "strokes": [], 1291 | "strokeWeight": 1.6879849433898926, 1292 | "strokeAlign": "INSIDE", 1293 | "backgroundColor": { 1294 | "r": 0, 1295 | "g": 0, 1296 | "b": 0, 1297 | "a": 0 1298 | }, 1299 | "absoluteBoundingBox": { 1300 | "x": 13364.6875, 1301 | "y": 13119.3779296875, 1302 | "width": 275.9183044433594, 1303 | "height": 275.53729248046875 1304 | }, 1305 | "absoluteRenderBounds": { 1306 | "x": 13364.6875, 1307 | "y": 13119.3779296875, 1308 | "width": 275.9183044433594, 1309 | "height": 275.53729248046875 1310 | }, 1311 | "constraints": { 1312 | "vertical": "SCALE", 1313 | "horizontal": "SCALE" 1314 | }, 1315 | "effects": [], 1316 | "interactions": [] 1317 | } 1318 | ], 1319 | "blendMode": "PASS_THROUGH", 1320 | "clipsContent": false, 1321 | "background": [], 1322 | "fills": [], 1323 | "strokes": [], 1324 | "strokeWeight": 1.6879849433898926, 1325 | "strokeAlign": "INSIDE", 1326 | "backgroundColor": { 1327 | "r": 0, 1328 | "g": 0, 1329 | "b": 0, 1330 | "a": 0 1331 | }, 1332 | "absoluteBoundingBox": { 1333 | "x": 13364.6875, 1334 | "y": 13119.3779296875, 1335 | "width": 275.9183044433594, 1336 | "height": 275.53729248046875 1337 | }, 1338 | "absoluteRenderBounds": { 1339 | "x": 13364.6875, 1340 | "y": 13119.3779296875, 1341 | "width": 275.9183044433594, 1342 | "height": 275.53729248046875 1343 | }, 1344 | "constraints": { 1345 | "vertical": "SCALE", 1346 | "horizontal": "SCALE" 1347 | }, 1348 | "effects": [], 1349 | "interactions": [] 1350 | } 1351 | ], 1352 | "blendMode": "PASS_THROUGH", 1353 | "clipsContent": true, 1354 | "background": [], 1355 | "fills": [], 1356 | "strokes": [ 1357 | { 1358 | "opacity": 0.4000000059604645, 1359 | "blendMode": "NORMAL", 1360 | "type": "SOLID", 1361 | "color": { 1362 | "r": 0.8509804010391235, 1363 | "g": 0.8509804010391235, 1364 | "b": 0.8509804010391235, 1365 | "a": 1 1366 | } 1367 | } 1368 | ], 1369 | "cornerRadius": 10, 1370 | "cornerSmoothing": 0, 1371 | "strokeWeight": 1, 1372 | "strokeAlign": "INSIDE", 1373 | "backgroundColor": { 1374 | "r": 0, 1375 | "g": 0, 1376 | "b": 0, 1377 | "a": 0 1378 | }, 1379 | "absoluteBoundingBox": { 1380 | "x": 13338, 1381 | "y": 12889, 1382 | "width": 322, 1383 | "height": 523 1384 | }, 1385 | "absoluteRenderBounds": { 1386 | "x": 13338, 1387 | "y": 12889, 1388 | "width": 322, 1389 | "height": 523 1390 | }, 1391 | "constraints": { 1392 | "vertical": "TOP", 1393 | "horizontal": "LEFT" 1394 | }, 1395 | "exportSettings": [ 1396 | { 1397 | "suffix": "", 1398 | "format": "PNG", 1399 | "constraint": { 1400 | "type": "SCALE", 1401 | "value": 1 1402 | } 1403 | } 1404 | ], 1405 | "effects": [], 1406 | "interactions": [] 1407 | }, 1408 | "components": {}, 1409 | "componentSets": {}, 1410 | "schemaVersion": 0, 1411 | "styles": {} 1412 | } 1413 | } 1414 | } ```