#
tokens: 42327/50000 22/22 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | }
```