figma-pixel-kit 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/figma-pixel-perfect/SKILL.md +31 -0
- package/.env.example +3 -0
- package/CLAUDE.md +32 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/package.json +56 -0
- package/src/cli.js +3042 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,3042 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync, readdirSync, statSync, realpathSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { chromium } from "playwright";
|
|
11
|
+
import pixelmatch from "pixelmatch";
|
|
12
|
+
import { PNG } from "pngjs";
|
|
13
|
+
|
|
14
|
+
const ROOT = process.cwd();
|
|
15
|
+
|
|
16
|
+
// Figma reference PNGs are exported at this scale. The compare step must capture
|
|
17
|
+
// the implemented app at the same deviceScaleFactor so pixelmatch compares
|
|
18
|
+
// like-for-like. This is persisted into spec.json so compare stays in sync.
|
|
19
|
+
const REFERENCE_SCALE = 2;
|
|
20
|
+
|
|
21
|
+
async function loadDotEnv() {
|
|
22
|
+
const envPath = path.join(ROOT, ".env");
|
|
23
|
+
if (!existsSync(envPath)) return;
|
|
24
|
+
|
|
25
|
+
const content = await readFile(envPath, "utf8");
|
|
26
|
+
for (const line of content.split(/\r?\n/)) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
29
|
+
|
|
30
|
+
const eqIndex = trimmed.indexOf("=");
|
|
31
|
+
if (eqIndex === -1) continue;
|
|
32
|
+
|
|
33
|
+
const key = trimmed.slice(0, eqIndex).trim();
|
|
34
|
+
const value = trimmed.slice(eqIndex + 1).trim().replace(/^["']|["']$/g, "");
|
|
35
|
+
if (!process.env[key]) process.env[key] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function usage() {
|
|
40
|
+
console.log(`
|
|
41
|
+
figma-pixel-kit
|
|
42
|
+
|
|
43
|
+
Main usage:
|
|
44
|
+
figma-pixel-kit "<figma-node-url>"
|
|
45
|
+
# If the node contains multiple same-width frames (1440px desktop by default), screens are auto-detected.
|
|
46
|
+
|
|
47
|
+
One-time setup (pick one auth method):
|
|
48
|
+
figma-pixel-kit token "figd_xxxxxxxxxxxxxxxxx" # Personal Access Token (simplest)
|
|
49
|
+
figma-pixel-kit login # OAuth via browser (needs oauth-setup first)
|
|
50
|
+
figma-pixel-kit doctor # verify the active credential against /v1/me
|
|
51
|
+
figma-pixel-kit init
|
|
52
|
+
|
|
53
|
+
OAuth (browser sign-in) setup:
|
|
54
|
+
# 1) Create an app at https://www.figma.com/developers/apps
|
|
55
|
+
# 2) Add redirect URL exactly: http://localhost:7113/callback
|
|
56
|
+
figma-pixel-kit oauth-setup <client_id> <client_secret>
|
|
57
|
+
figma-pixel-kit login # opens the browser, captures the token automatically
|
|
58
|
+
figma-pixel-kit logout # clear stored OAuth tokens
|
|
59
|
+
|
|
60
|
+
Advanced usage:
|
|
61
|
+
figma-pixel-kit inspect "<figma-node-url>"
|
|
62
|
+
figma-pixel-kit analyze "<board-url>" [detection options]
|
|
63
|
+
figma-pixel-kit implement "<figma-node-url>" [detection options]
|
|
64
|
+
figma-pixel-kit export "<figma-node-url>" [detection options]
|
|
65
|
+
figma-pixel-kit compare "<screen-id-or-output-folder>" --url "http://localhost:3000" [compare options]
|
|
66
|
+
|
|
67
|
+
Examples:
|
|
68
|
+
figma-pixel-kit "https://www.figma.com/design/FILE_KEY/File-Name?node-id=21886-287699&m=dev"
|
|
69
|
+
figma-pixel-kit "<board-url>" --mobile
|
|
70
|
+
figma-pixel-kit compare "file-name-21886-287699" --url "http://localhost:3000" --selector "[data-figma-screen='21886:287699']"
|
|
71
|
+
|
|
72
|
+
Export/detection options (export / analyze / implement / main):
|
|
73
|
+
--mobile Preset for mobile boards (screen-width 390, tolerance 60, min-height 400).
|
|
74
|
+
--screen-width <px> Expected screen frame width. Default: 1440
|
|
75
|
+
--width-tolerance <px> Allowed width deviation. Default: 12
|
|
76
|
+
--min-height <px> Minimum screen height. Default: 500
|
|
77
|
+
--max-height <px> Maximum screen height. Default: 4000
|
|
78
|
+
--scale <1-4> reference.png export scale (stored in spec.json). Default: 2
|
|
79
|
+
|
|
80
|
+
Compare options:
|
|
81
|
+
--url <url> Required. Local page URL to capture.
|
|
82
|
+
--selector <css> Optional. Capture only this element instead of the viewport.
|
|
83
|
+
--threshold <number> Pixelmatch threshold. Default: 0.12
|
|
84
|
+
--max-diff-ratio <number> Pass/fail ratio. Default: 0.03
|
|
85
|
+
--wait <ms> Wait before screenshot. Default: 1000
|
|
86
|
+
--wait-until <state> Playwright nav wait: load | domcontentloaded | networkidle. Default: load
|
|
87
|
+
--full-page Capture full page instead of viewport. Use carefully.
|
|
88
|
+
--ignore-text Mask text regions so font rendering differences don't count (layout/color only).
|
|
89
|
+
# The app is captured at the reference scale stored in spec.json so pixels match 1:1.
|
|
90
|
+
|
|
91
|
+
Debug:
|
|
92
|
+
FIGMA_PIXEL_KIT_DEBUG=1 Print full stack traces on error.
|
|
93
|
+
|
|
94
|
+
Token lookup order:
|
|
95
|
+
1. FIGMA_TOKEN environment variable
|
|
96
|
+
2. .env file in the current project
|
|
97
|
+
3. ~/.figma-pixel-kit/config.json created by: figma-pixel-kit token "figd_xxx"
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
function parseArgs(argv) {
|
|
101
|
+
const args = { _: [] };
|
|
102
|
+
for (let i = 0; i < argv.length; i++) {
|
|
103
|
+
const item = argv[i];
|
|
104
|
+
if (!item.startsWith("--")) {
|
|
105
|
+
args._.push(item);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const key = item.slice(2);
|
|
110
|
+
const next = argv[i + 1];
|
|
111
|
+
if (!next || next.startsWith("--")) {
|
|
112
|
+
args[key] = true;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
args[key] = next;
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
119
|
+
return args;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
// Figma OAuth2 endpoints/scope. Centralized so they're a one-line fix if Figma changes them.
|
|
124
|
+
// Personal Access Tokens use the X-Figma-Token header; OAuth tokens use Authorization: Bearer.
|
|
125
|
+
const OAUTH_AUTHORIZE_URL = "https://www.figma.com/oauth";
|
|
126
|
+
const OAUTH_TOKEN_URL = "https://api.figma.com/v1/oauth/token";
|
|
127
|
+
const OAUTH_REFRESH_URL = "https://api.figma.com/v1/oauth/refresh";
|
|
128
|
+
const OAUTH_SCOPE = "files:read";
|
|
129
|
+
const OAUTH_PORT = 7113; // redirect_uri must be registered EXACTLY as http://localhost:7113/callback
|
|
130
|
+
const OAUTH_REDIRECT_URI = `http://localhost:${OAUTH_PORT}/callback`;
|
|
131
|
+
|
|
132
|
+
// The header style depends on which credential resolveFigmaToken() selected this run.
|
|
133
|
+
let CURRENT_AUTH_TYPE = "pat";
|
|
134
|
+
|
|
135
|
+
function getHomeConfigPath() {
|
|
136
|
+
return path.join(os.homedir(), ".figma-pixel-kit", "config.json");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function loadHomeConfig() {
|
|
140
|
+
const configPath = getHomeConfigPath();
|
|
141
|
+
if (!existsSync(configPath)) return {};
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(await readFile(configPath, "utf8"));
|
|
144
|
+
} catch {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Merge-and-save so writing a PAT doesn't wipe OAuth creds (and vice versa).
|
|
150
|
+
async function saveHomeConfig(patch) {
|
|
151
|
+
const configPath = getHomeConfigPath();
|
|
152
|
+
const current = await loadHomeConfig();
|
|
153
|
+
const next = { ...current, ...patch };
|
|
154
|
+
await mkdir(path.dirname(configPath), { recursive: true });
|
|
155
|
+
await writeFile(configPath, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 });
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function saveTokenCommand(token) {
|
|
160
|
+
if (!token || token === true) {
|
|
161
|
+
throw new Error('Missing token. Example: figma-pixel-kit token "figd_xxxxxxxxx"');
|
|
162
|
+
}
|
|
163
|
+
await saveHomeConfig({ FIGMA_TOKEN: token });
|
|
164
|
+
console.log(`Saved Figma token to ${getHomeConfigPath()}`);
|
|
165
|
+
console.log('From now on you can run: figma-pixel-kit "<figma-node-url>"');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveOAuthClient(homeConfig) {
|
|
169
|
+
const clientId = process.env.FIGMA_OAUTH_CLIENT_ID || homeConfig.FIGMA_OAUTH_CLIENT_ID;
|
|
170
|
+
const clientSecret = process.env.FIGMA_OAUTH_CLIENT_SECRET || homeConfig.FIGMA_OAUTH_CLIENT_SECRET;
|
|
171
|
+
return clientId && clientSecret ? { clientId, clientSecret } : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Refresh an expiring OAuth access token (Figma access tokens are short-lived; refresh tokens persist).
|
|
175
|
+
async function refreshOAuthToken(oauth, client) {
|
|
176
|
+
const body = new URLSearchParams({
|
|
177
|
+
client_id: client.clientId,
|
|
178
|
+
client_secret: client.clientSecret,
|
|
179
|
+
refresh_token: oauth.refresh_token
|
|
180
|
+
});
|
|
181
|
+
const res = await fetch(OAUTH_REFRESH_URL, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
184
|
+
body
|
|
185
|
+
});
|
|
186
|
+
const text = await res.text();
|
|
187
|
+
if (!res.ok) throw new Error(`OAuth refresh failed ${res.status}: ${text}`);
|
|
188
|
+
const data = JSON.parse(text);
|
|
189
|
+
const updated = {
|
|
190
|
+
access_token: data.access_token,
|
|
191
|
+
refresh_token: data.refresh_token || oauth.refresh_token,
|
|
192
|
+
expires_at: Date.now() + Number(data.expires_in || 0) * 1000
|
|
193
|
+
};
|
|
194
|
+
await saveHomeConfig({ FIGMA_OAUTH: updated });
|
|
195
|
+
return updated;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Token lookup order:
|
|
199
|
+
// 1. FIGMA_TOKEN env / .env (PAT — explicit override always wins)
|
|
200
|
+
// 2. ~/.figma-pixel-kit FIGMA_OAUTH (OAuth — deliberate `login`, auto-refreshed)
|
|
201
|
+
// 3. ~/.figma-pixel-kit FIGMA_TOKEN (PAT saved via `token`)
|
|
202
|
+
async function resolveFigmaToken() {
|
|
203
|
+
await loadDotEnv();
|
|
204
|
+
if (process.env.FIGMA_TOKEN) {
|
|
205
|
+
CURRENT_AUTH_TYPE = "pat";
|
|
206
|
+
return process.env.FIGMA_TOKEN;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const homeConfig = await loadHomeConfig();
|
|
210
|
+
|
|
211
|
+
if (homeConfig.FIGMA_OAUTH?.access_token) {
|
|
212
|
+
let oauth = homeConfig.FIGMA_OAUTH;
|
|
213
|
+
const client = resolveOAuthClient(homeConfig);
|
|
214
|
+
// Refresh slightly early to avoid mid-run expiry.
|
|
215
|
+
if (oauth.expires_at && Date.now() >= oauth.expires_at - 60_000 && oauth.refresh_token && client) {
|
|
216
|
+
try {
|
|
217
|
+
oauth = await refreshOAuthToken(oauth, client);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.error(`Warning: OAuth token refresh failed (${e.message.split("\n")[0]}). Re-run: figma-pixel-kit login`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
CURRENT_AUTH_TYPE = "oauth";
|
|
223
|
+
return oauth.access_token;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (homeConfig.FIGMA_TOKEN) {
|
|
227
|
+
CURRENT_AUTH_TYPE = "pat";
|
|
228
|
+
return homeConfig.FIGMA_TOKEN;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function doctorCommand() {
|
|
235
|
+
const token = await resolveFigmaToken();
|
|
236
|
+
|
|
237
|
+
if (!token) {
|
|
238
|
+
throw new Error('No Figma credential found. Run: figma-pixel-kit token "figd_xxxxxxxxx" (or: figma-pixel-kit login)');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log(`Auth method: ${CURRENT_AUTH_TYPE === "oauth" ? "OAuth (Bearer)" : "Personal Access Token"}`);
|
|
242
|
+
console.log("Testing credential with /v1/me ...");
|
|
243
|
+
|
|
244
|
+
const me = await figmaFetch("https://api.figma.com/v1/me", token);
|
|
245
|
+
|
|
246
|
+
console.log("Figma credential is valid.");
|
|
247
|
+
if (me.handle) console.log(`Handle: ${me.handle}`);
|
|
248
|
+
if (me.email) console.log(`Email: ${me.email}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function openBrowser(url) {
|
|
252
|
+
// Best-effort cross-platform "open this URL". Failure is non-fatal (we print the URL too).
|
|
253
|
+
const cmd = process.platform === "darwin" ? "open"
|
|
254
|
+
: process.platform === "win32" ? "cmd"
|
|
255
|
+
: "xdg-open";
|
|
256
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
257
|
+
try {
|
|
258
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
259
|
+
child.on("error", () => {});
|
|
260
|
+
child.unref();
|
|
261
|
+
} catch {
|
|
262
|
+
// ignore — the URL is printed for manual opening
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function oauthSetupCommand(clientId, clientSecret) {
|
|
267
|
+
if (!clientId || clientId === true || !clientSecret || clientSecret === true) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"Usage: figma-pixel-kit oauth-setup <client_id> <client_secret>\n" +
|
|
270
|
+
"Create an app at https://www.figma.com/developers/apps, add redirect URL " +
|
|
271
|
+
`${OAUTH_REDIRECT_URI} , then run this with its client id/secret.`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
await saveHomeConfig({ FIGMA_OAUTH_CLIENT_ID: clientId, FIGMA_OAUTH_CLIENT_SECRET: clientSecret });
|
|
275
|
+
console.log(`Saved OAuth client credentials to ${getHomeConfigPath()}`);
|
|
276
|
+
console.log("Next: figma-pixel-kit login");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function logoutCommand() {
|
|
280
|
+
await saveHomeConfig({ FIGMA_OAUTH: undefined });
|
|
281
|
+
console.log("Cleared stored OAuth tokens. (Client credentials and any PAT are kept.)");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function exchangeOAuthCode(code, client) {
|
|
285
|
+
const body = new URLSearchParams({
|
|
286
|
+
client_id: client.clientId,
|
|
287
|
+
client_secret: client.clientSecret,
|
|
288
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
289
|
+
code,
|
|
290
|
+
grant_type: "authorization_code"
|
|
291
|
+
});
|
|
292
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
295
|
+
body
|
|
296
|
+
});
|
|
297
|
+
const text = await res.text();
|
|
298
|
+
if (!res.ok) throw new Error(`OAuth token exchange failed ${res.status}: ${text}`);
|
|
299
|
+
return JSON.parse(text);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function loginCommand() {
|
|
303
|
+
const homeConfig = await loadHomeConfig();
|
|
304
|
+
const client = resolveOAuthClient(homeConfig);
|
|
305
|
+
if (!client) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
"No OAuth client configured.\n" +
|
|
308
|
+
"1. Create an app at https://www.figma.com/developers/apps\n" +
|
|
309
|
+
`2. Add redirect URL exactly: ${OAUTH_REDIRECT_URI}\n` +
|
|
310
|
+
"3. Run: figma-pixel-kit oauth-setup <client_id> <client_secret>\n" +
|
|
311
|
+
"Then: figma-pixel-kit login\n" +
|
|
312
|
+
'(Or skip OAuth entirely and use: figma-pixel-kit token "figd_xxx")'
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const state = randomBytes(16).toString("hex");
|
|
317
|
+
const authUrl = new URL(OAUTH_AUTHORIZE_URL);
|
|
318
|
+
authUrl.searchParams.set("client_id", client.clientId);
|
|
319
|
+
authUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
|
|
320
|
+
authUrl.searchParams.set("scope", OAUTH_SCOPE);
|
|
321
|
+
authUrl.searchParams.set("state", state);
|
|
322
|
+
authUrl.searchParams.set("response_type", "code");
|
|
323
|
+
|
|
324
|
+
const tokenData = await new Promise((resolve, reject) => {
|
|
325
|
+
const server = createServer(async (req, res) => {
|
|
326
|
+
try {
|
|
327
|
+
const reqUrl = new URL(req.url, OAUTH_REDIRECT_URI);
|
|
328
|
+
if (reqUrl.pathname !== "/callback") {
|
|
329
|
+
res.writeHead(404).end("Not found");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const err = reqUrl.searchParams.get("error");
|
|
333
|
+
const code = reqUrl.searchParams.get("code");
|
|
334
|
+
const returnedState = reqUrl.searchParams.get("state");
|
|
335
|
+
const finish = (status, html) => {
|
|
336
|
+
res.writeHead(status, { "Content-Type": "text/html" }).end(html);
|
|
337
|
+
};
|
|
338
|
+
if (err) {
|
|
339
|
+
finish(400, `<h2>Authorization failed</h2><p>${err}</p><p>You can close this tab.</p>`);
|
|
340
|
+
server.close();
|
|
341
|
+
reject(new Error(`Authorization denied: ${err}`));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (!code || returnedState !== state) {
|
|
345
|
+
finish(400, "<h2>Invalid callback</h2><p>State mismatch. Close this tab and retry.</p>");
|
|
346
|
+
server.close();
|
|
347
|
+
reject(new Error("OAuth state mismatch — aborting for safety."));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const data = await exchangeOAuthCode(code, client);
|
|
351
|
+
finish(200, "<h2>✓ figma-pixel-kit is now connected</h2><p>You can close this tab and return to the terminal.</p>");
|
|
352
|
+
server.close();
|
|
353
|
+
resolve(data);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
try { res.writeHead(500).end("Error"); } catch {}
|
|
356
|
+
server.close();
|
|
357
|
+
reject(e);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
server.on("error", (e) => {
|
|
361
|
+
reject(new Error(
|
|
362
|
+
e.code === "EADDRINUSE"
|
|
363
|
+
? `Port ${OAUTH_PORT} is in use. Free it and retry (the redirect URL must be ${OAUTH_REDIRECT_URI}).`
|
|
364
|
+
: `Local callback server error: ${e.message}`
|
|
365
|
+
));
|
|
366
|
+
});
|
|
367
|
+
server.listen(OAUTH_PORT, () => {
|
|
368
|
+
console.log("Opening Figma authorization in your browser...");
|
|
369
|
+
console.log(`If it doesn't open, visit:\n${authUrl.toString()}`);
|
|
370
|
+
openBrowser(authUrl.toString());
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const oauth = {
|
|
375
|
+
access_token: tokenData.access_token,
|
|
376
|
+
refresh_token: tokenData.refresh_token,
|
|
377
|
+
expires_at: Date.now() + Number(tokenData.expires_in || 0) * 1000
|
|
378
|
+
};
|
|
379
|
+
await saveHomeConfig({ FIGMA_OAUTH: oauth });
|
|
380
|
+
console.log(`\nConnected. Saved OAuth tokens to ${getHomeConfigPath()}`);
|
|
381
|
+
console.log('You can now run: figma-pixel-kit "<figma-node-url>"');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function writeFileIfMissing(filePath, content) {
|
|
385
|
+
if (existsSync(filePath)) return false;
|
|
386
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
387
|
+
await writeFile(filePath, content, "utf8");
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function buildVSCodeInstructions() {
|
|
392
|
+
return `# Figma Pixel Kit — VS Code Workflow
|
|
393
|
+
|
|
394
|
+
Use this project with VS Code and any AI coding assistant by giving it the generated file:
|
|
395
|
+
|
|
396
|
+
\`design-ai/<screen-id>/prompt-for-claude.md\`
|
|
397
|
+
|
|
398
|
+
## Standard flow
|
|
399
|
+
|
|
400
|
+
1. Export a Figma node:
|
|
401
|
+
|
|
402
|
+
\`\`\`bash
|
|
403
|
+
figma-pixel-kit "<figma-node-url>"
|
|
404
|
+
\`\`\`
|
|
405
|
+
|
|
406
|
+
2. Open the generated folder under \`design-ai/<screen-id>/\`.
|
|
407
|
+
|
|
408
|
+
3. Ask the AI assistant to implement using:
|
|
409
|
+
|
|
410
|
+
- \`reference.png\` as visual truth
|
|
411
|
+
- \`inventory.md\` as the complete element checklist
|
|
412
|
+
- \`spec.json\` as exact bounds/style data
|
|
413
|
+
- \`index.html\` only as a pixel-preview reference
|
|
414
|
+
|
|
415
|
+
4. Add the generated selector to your implemented root element:
|
|
416
|
+
|
|
417
|
+
\`\`\`tsx
|
|
418
|
+
<section data-figma-screen="NODE_ID_HERE">
|
|
419
|
+
...
|
|
420
|
+
</section>
|
|
421
|
+
\`\`\`
|
|
422
|
+
|
|
423
|
+
5. Run visual compare:
|
|
424
|
+
|
|
425
|
+
\`\`\`bash
|
|
426
|
+
figma-pixel-kit compare "<screen-id>" --url "http://localhost:3000" --selector "[data-figma-screen='NODE_ID_HERE']"
|
|
427
|
+
\`\`\`
|
|
428
|
+
|
|
429
|
+
6. Give \`visual-report/fix-prompt.md\`, \`reference.png\`, \`actual.png\`, and \`diff.png\` back to the AI assistant.
|
|
430
|
+
`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function buildVSCodeTasks() {
|
|
434
|
+
return JSON.stringify({
|
|
435
|
+
version: "2.0.0",
|
|
436
|
+
tasks: [
|
|
437
|
+
{
|
|
438
|
+
label: "figma-pixel-kit: compare screen",
|
|
439
|
+
type: "shell",
|
|
440
|
+
command: "figma-pixel-kit compare ${input:screenId} --url ${input:localUrl} --selector ${input:selector}",
|
|
441
|
+
problemMatcher: []
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
label: "figma-pixel-kit: export node",
|
|
445
|
+
type: "shell",
|
|
446
|
+
command: "figma-pixel-kit ${input:figmaUrl}",
|
|
447
|
+
problemMatcher: []
|
|
448
|
+
}
|
|
449
|
+
],
|
|
450
|
+
inputs: [
|
|
451
|
+
{
|
|
452
|
+
id: "figmaUrl",
|
|
453
|
+
type: "promptString",
|
|
454
|
+
description: "Figma selected node URL"
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
id: "screenId",
|
|
458
|
+
type: "promptString",
|
|
459
|
+
description: "design-ai screen id, for example: procright-web-1-21886-287699"
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
id: "localUrl",
|
|
463
|
+
type: "promptString",
|
|
464
|
+
description: "Local app URL",
|
|
465
|
+
default: "http://localhost:3000"
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
id: "selector",
|
|
469
|
+
type: "promptString",
|
|
470
|
+
description: "CSS selector to capture",
|
|
471
|
+
default: "[data-figma-screen='NODE_ID_HERE']"
|
|
472
|
+
}
|
|
473
|
+
]
|
|
474
|
+
}, null, 2);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function buildRootRules() {
|
|
478
|
+
return `# Figma Pixel Kit Rules
|
|
479
|
+
|
|
480
|
+
This project uses Figma Pixel Kit for Figma implementation QA.
|
|
481
|
+
|
|
482
|
+
## Main rule
|
|
483
|
+
|
|
484
|
+
Do not implement directly from a Figma URL alone. Use the generated implementation package under \`design-ai/<screen-id>/\`.
|
|
485
|
+
|
|
486
|
+
## Source priority
|
|
487
|
+
|
|
488
|
+
1. \`reference.png\` is the visual truth.
|
|
489
|
+
2. \`inventory.md\` is the no-missing-elements checklist.
|
|
490
|
+
3. \`spec.json\` contains exact bounds, text and styles.
|
|
491
|
+
4. \`index.html\` is only a pixel preview, not production code.
|
|
492
|
+
|
|
493
|
+
## Implementation rules
|
|
494
|
+
|
|
495
|
+
- Do not redesign.
|
|
496
|
+
- Do not simplify.
|
|
497
|
+
- Do not skip visible elements.
|
|
498
|
+
- Do not create duplicate primitive components if the app already has Button/Card/Input/Badge/Modal/Container.
|
|
499
|
+
- Implement section by section.
|
|
500
|
+
- Add \`data-figma-screen="<node-id>"\` to the implemented root element.
|
|
501
|
+
- After implementation, run \`figma-pixel-kit compare\` and fix from \`visual-report/fix-prompt.md\`.
|
|
502
|
+
`;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function buildClaudeSkill() {
|
|
506
|
+
return `---
|
|
507
|
+
name: figma-pixel-perfect
|
|
508
|
+
description: Implements read-only Figma screens from figma-pixel-kit output with visual regression validation.
|
|
509
|
+
---
|
|
510
|
+
|
|
511
|
+
# Figma Pixel Perfect Skill
|
|
512
|
+
|
|
513
|
+
Use this skill when a task references a \`design-ai/<screen-id>/\` folder created by Figma Pixel Kit.
|
|
514
|
+
|
|
515
|
+
## Required behavior
|
|
516
|
+
|
|
517
|
+
1. Read \`prompt-for-claude.md\` first.
|
|
518
|
+
2. Use \`reference.png\` as visual truth.
|
|
519
|
+
3. Use \`inventory.md\` as the complete checklist.
|
|
520
|
+
4. Use \`spec.json\` for exact bounds, text and style data.
|
|
521
|
+
5. Use \`index.html\` only as a pixel preview, not production code.
|
|
522
|
+
6. Do not skip visible elements.
|
|
523
|
+
7. Do not redesign.
|
|
524
|
+
8. Reuse existing project components.
|
|
525
|
+
9. Add \`data-figma-screen\` to the implemented root.
|
|
526
|
+
10. After coding, run visual compare and fix from \`visual-report/fix-prompt.md\`.
|
|
527
|
+
|
|
528
|
+
## Final response checklist
|
|
529
|
+
|
|
530
|
+
Always report:
|
|
531
|
+
|
|
532
|
+
- Files changed
|
|
533
|
+
- Components reused
|
|
534
|
+
- New components created, if any
|
|
535
|
+
- Visual compare result
|
|
536
|
+
- Missing/approximate elements, if any
|
|
537
|
+
`;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function initCommand({ silent = false } = {}) {
|
|
541
|
+
const created = [];
|
|
542
|
+
|
|
543
|
+
if (await writeFileIfMissing(path.join(ROOT, "FIGMA_PIXEL_KIT.md"), buildRootRules())) created.push("FIGMA_PIXEL_KIT.md");
|
|
544
|
+
if (await writeFileIfMissing(path.join(ROOT, ".vscode", "figma-pixel-kit.instructions.md"), buildVSCodeInstructions())) created.push(".vscode/figma-pixel-kit.instructions.md");
|
|
545
|
+
|
|
546
|
+
const tasksPath = path.join(ROOT, ".vscode", "tasks.json");
|
|
547
|
+
const taskExamplePath = path.join(ROOT, ".vscode", "figma-pixel-kit.tasks.example.json");
|
|
548
|
+
if (await writeFileIfMissing(tasksPath, buildVSCodeTasks())) {
|
|
549
|
+
created.push(".vscode/tasks.json");
|
|
550
|
+
} else if (await writeFileIfMissing(taskExamplePath, buildVSCodeTasks())) {
|
|
551
|
+
created.push(".vscode/figma-pixel-kit.tasks.example.json");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (await writeFileIfMissing(path.join(ROOT, ".claude", "skills", "figma-pixel-perfect", "SKILL.md"), buildClaudeSkill())) {
|
|
555
|
+
created.push(".claude/skills/figma-pixel-perfect/SKILL.md");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (!silent) {
|
|
559
|
+
if (created.length) {
|
|
560
|
+
console.log("Created project integration files:");
|
|
561
|
+
for (const file of created) console.log(`- ${file}`);
|
|
562
|
+
} else {
|
|
563
|
+
console.log("Project integration files already exist. Nothing changed.");
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return created;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function looksLikeFigmaUrl(value) {
|
|
571
|
+
return typeof value === "string" && /^https?:\/\/(www\.)?figma\.com\//.test(value);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function parseFigmaUrl(rawUrl) {
|
|
575
|
+
let url;
|
|
576
|
+
try {
|
|
577
|
+
url = new URL(rawUrl);
|
|
578
|
+
} catch {
|
|
579
|
+
throw new Error(`Invalid URL: ${rawUrl}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (!/(^|\.)figma\.com$/.test(url.hostname)) {
|
|
583
|
+
throw new Error(`Not a figma.com URL: ${rawUrl}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
587
|
+
|
|
588
|
+
// Supported: /design/:fileKey/:fileName and /file/:fileKey/:fileName
|
|
589
|
+
// Branch: /design/:fileKey/branch/:branchKey/:fileName (node lives in the branch file)
|
|
590
|
+
const fileTypeIndex = parts.findIndex((p) => ["design", "file"].includes(p));
|
|
591
|
+
if (fileTypeIndex === -1) {
|
|
592
|
+
if (parts.includes("proto")) {
|
|
593
|
+
throw new Error("Prototype URLs (/proto/) are not supported. Open the file in design mode (/design/) and copy the node link.");
|
|
594
|
+
}
|
|
595
|
+
if (parts.includes("community")) {
|
|
596
|
+
throw new Error("Community URLs are not supported. Duplicate the file to your drafts, then copy a /design/ node link.");
|
|
597
|
+
}
|
|
598
|
+
throw new Error("Unsupported Figma URL. Expected a /design/ or /file/ URL containing node-id=...");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
let fileKey = parts[fileTypeIndex + 1];
|
|
602
|
+
let fileNameIndex = fileTypeIndex + 2;
|
|
603
|
+
|
|
604
|
+
// Branch URLs address the branch by its own key, not the parent file key.
|
|
605
|
+
if (parts[fileTypeIndex + 2] === "branch" && parts[fileTypeIndex + 3]) {
|
|
606
|
+
fileKey = parts[fileTypeIndex + 3];
|
|
607
|
+
fileNameIndex = fileTypeIndex + 4;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// node-id may live in the query string or in the URL fragment.
|
|
611
|
+
const rawNodeId =
|
|
612
|
+
url.searchParams.get("node-id") ||
|
|
613
|
+
new URLSearchParams(url.hash.replace(/^#/, "")).get("node-id");
|
|
614
|
+
|
|
615
|
+
if (!fileKey || !rawNodeId) {
|
|
616
|
+
throw new Error("Invalid Figma URL. Could not parse fileKey or node-id. Select a node in Figma and copy its link (it must contain node-id=...).");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
fileKey,
|
|
621
|
+
nodeId: rawNodeId.replaceAll("-", ":"),
|
|
622
|
+
rawNodeId,
|
|
623
|
+
fileName: decodeURIComponent(parts[fileNameIndex] || fileKey)
|
|
624
|
+
.replace(/[^a-z0-9-_]+/gi, "-")
|
|
625
|
+
.replace(/^-+|-+$/g, "")
|
|
626
|
+
.toLowerCase() || fileKey
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function figmaFetch(url, token, { retries = 2 } = {}) {
|
|
631
|
+
for (let attempt = 0; ; attempt++) {
|
|
632
|
+
let response;
|
|
633
|
+
try {
|
|
634
|
+
// PATs use X-Figma-Token; OAuth access tokens use Authorization: Bearer.
|
|
635
|
+
// Using the wrong header returns 403 Invalid token.
|
|
636
|
+
const headers = CURRENT_AUTH_TYPE === "oauth"
|
|
637
|
+
? { Authorization: `Bearer ${token}` }
|
|
638
|
+
: { "X-Figma-Token": token };
|
|
639
|
+
response = await fetch(url, { headers });
|
|
640
|
+
} catch (networkError) {
|
|
641
|
+
if (attempt < retries) {
|
|
642
|
+
await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 1000));
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
throw new Error(`Network error reaching Figma API: ${networkError.message}\nCheck your internet connection.`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (response.ok) return response.json();
|
|
649
|
+
|
|
650
|
+
// Retry transient throttling / server errors with simple backoff.
|
|
651
|
+
if ((response.status === 429 || response.status >= 500) && attempt < retries) {
|
|
652
|
+
const retryAfter = Number(response.headers.get("retry-after")) || (attempt + 1) * 2;
|
|
653
|
+
console.log(`Figma API ${response.status}; retrying in ${retryAfter}s (attempt ${attempt + 1}/${retries})...`);
|
|
654
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000));
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const text = await response.text().catch(() => "");
|
|
659
|
+
let hint = "";
|
|
660
|
+
if (response.status === 403) hint = "\nHint: token invalid or no access to this file. Verify with: figma-pixel-kit doctor";
|
|
661
|
+
if (response.status === 404) hint = "\nHint: file or node not found. Branch URLs must use the branch key — re-copy the link from the branch.";
|
|
662
|
+
if (response.status === 429) hint = "\nHint: rate limited by Figma. Wait a moment and retry.";
|
|
663
|
+
throw new Error(`Figma API error ${response.status}: ${response.statusText}\n${text}${hint}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function fetchImageFillUrls(fileKey, token) {
|
|
668
|
+
// Returns { <imageRef>: <temporary S3 url> } for every image fill in the file.
|
|
669
|
+
try {
|
|
670
|
+
const data = await figmaFetch(`https://api.figma.com/v1/files/${fileKey}/images`, token);
|
|
671
|
+
return data?.meta?.images || {};
|
|
672
|
+
} catch {
|
|
673
|
+
// Image fills are a best-effort enhancement for the debug renderer; never fail export over them.
|
|
674
|
+
return {};
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function resolveExportScale(value) {
|
|
679
|
+
// Figma's images API supports scales between 0.01 and 4; we restrict to integer 1–4.
|
|
680
|
+
const n = Number(value);
|
|
681
|
+
if (Number.isFinite(n) && n >= 1 && n <= 4) return Math.round(n);
|
|
682
|
+
return REFERENCE_SCALE;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async function fetchRenderedImageUrls(fileKey, ids, token, scale) {
|
|
686
|
+
// Render one /v1/images call for the given ids. Returns { nodeId: pngUrl }.
|
|
687
|
+
if (!ids.length) return {};
|
|
688
|
+
const url = new URL(`https://api.figma.com/v1/images/${fileKey}`);
|
|
689
|
+
url.searchParams.set("ids", ids.join(","));
|
|
690
|
+
url.searchParams.set("format", "png");
|
|
691
|
+
url.searchParams.set("scale", String(scale));
|
|
692
|
+
url.searchParams.set("use_absolute_bounds", "true");
|
|
693
|
+
const data = await figmaFetch(url.toString(), token);
|
|
694
|
+
if (data.err) throw new Error(`Figma image render failed: ${data.err}`);
|
|
695
|
+
return data.images || {};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Render nodes as SVG (for custom/non-library icons). Returns { nodeId: svgUrl }.
|
|
699
|
+
async function fetchRenderedSvgUrls(fileKey, ids, token) {
|
|
700
|
+
if (!ids.length) return {};
|
|
701
|
+
const url = new URL(`https://api.figma.com/v1/images/${fileKey}`);
|
|
702
|
+
url.searchParams.set("ids", ids.join(","));
|
|
703
|
+
url.searchParams.set("format", "svg");
|
|
704
|
+
url.searchParams.set("use_absolute_bounds", "true");
|
|
705
|
+
const data = await figmaFetch(url.toString(), token);
|
|
706
|
+
if (data.err) throw new Error(`Figma SVG render failed: ${data.err}`);
|
|
707
|
+
return data.images || {};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Render many screens with fewer round-trips than one-call-per-screen, while staying under Figma's
|
|
711
|
+
// per-request render-time budget (large 2x screens make a single all-ids call return
|
|
712
|
+
// "Render timeout, try requesting fewer or smaller images"). Renders in small chunks and, if a chunk
|
|
713
|
+
// times out, splits it down to single ids. Any id that still fails is left out and rendered later
|
|
714
|
+
// per-screen by exportScreenFromNode.
|
|
715
|
+
async function renderReferenceImages(fileKey, ids, token, scale, { chunkSize = 2 } = {}) {
|
|
716
|
+
const urls = {};
|
|
717
|
+
for (let i = 0; i < ids.length; i += chunkSize) {
|
|
718
|
+
const chunk = ids.slice(i, i + chunkSize);
|
|
719
|
+
try {
|
|
720
|
+
Object.assign(urls, await fetchRenderedImageUrls(fileKey, chunk, token, scale));
|
|
721
|
+
} catch (e) {
|
|
722
|
+
if (chunk.length > 1) {
|
|
723
|
+
// Too much rendered at once (render timeout) — retry each id on its own.
|
|
724
|
+
for (const id of chunk) {
|
|
725
|
+
try {
|
|
726
|
+
Object.assign(urls, await fetchRenderedImageUrls(fileKey, [id], token, scale));
|
|
727
|
+
} catch {
|
|
728
|
+
// leave missing; exportScreenFromNode renders it individually as a last resort
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// single-id chunk failed: leave missing for the per-screen fallback
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
return urls;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async function downloadFile(url, outputPath) {
|
|
739
|
+
const response = await fetch(url);
|
|
740
|
+
if (!response.ok) {
|
|
741
|
+
throw new Error(`Download failed ${response.status}: ${url}`);
|
|
742
|
+
}
|
|
743
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
744
|
+
await writeFile(outputPath, buffer);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function getNodeBounds(node) {
|
|
748
|
+
return node.absoluteBoundingBox || node.absoluteRenderBounds || null;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isVisible(node) {
|
|
752
|
+
return node.visible !== false && (node.opacity ?? 1) > 0;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function flattenNodes(node, result = []) {
|
|
756
|
+
if (!node || !isVisible(node)) return result;
|
|
757
|
+
result.push(node);
|
|
758
|
+
|
|
759
|
+
if (Array.isArray(node.children)) {
|
|
760
|
+
for (const child of node.children) {
|
|
761
|
+
flattenNodes(child, result);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function rgbaToCss(color, opacity = 1) {
|
|
769
|
+
if (!color) return "transparent";
|
|
770
|
+
|
|
771
|
+
const r = Math.round((color.r ?? 0) * 255);
|
|
772
|
+
const g = Math.round((color.g ?? 0) * 255);
|
|
773
|
+
const b = Math.round((color.b ?? 0) * 255);
|
|
774
|
+
const a = Number(((color.a ?? 1) * opacity).toFixed(3));
|
|
775
|
+
|
|
776
|
+
if (a >= 1) return `rgb(${r}, ${g}, ${b})`;
|
|
777
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function firstSolidFill(node) {
|
|
781
|
+
const fills = Array.isArray(node.fills) ? node.fills : [];
|
|
782
|
+
return fills.find((fill) => fill.visible !== false && fill.type === "SOLID");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function firstImageFill(node) {
|
|
786
|
+
const fills = Array.isArray(node.fills) ? node.fills : [];
|
|
787
|
+
return fills.find((fill) => fill.visible !== false && fill.type === "IMAGE" && fill.imageRef);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function nodeCss(node, rootBounds, imageRefMap = {}) {
|
|
791
|
+
const bounds = getNodeBounds(node);
|
|
792
|
+
if (!bounds) return "";
|
|
793
|
+
|
|
794
|
+
const left = Math.round(bounds.x - rootBounds.x);
|
|
795
|
+
const top = Math.round(bounds.y - rootBounds.y);
|
|
796
|
+
const width = Math.round(bounds.width);
|
|
797
|
+
const height = Math.round(bounds.height);
|
|
798
|
+
|
|
799
|
+
const parts = [
|
|
800
|
+
"position:absolute",
|
|
801
|
+
`left:${left}px`,
|
|
802
|
+
`top:${top}px`,
|
|
803
|
+
`width:${width}px`,
|
|
804
|
+
`height:${height}px`
|
|
805
|
+
];
|
|
806
|
+
|
|
807
|
+
// Solid background only for non-text nodes; for TEXT the solid fill is the glyph color (handled below).
|
|
808
|
+
const fill = firstSolidFill(node);
|
|
809
|
+
if (fill && node.type !== "TEXT") {
|
|
810
|
+
parts.push(`background-color:${rgbaToCss(fill.color, fill.opacity ?? node.opacity ?? 1)}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Image fills: reference the asset downloaded into ./assets during export.
|
|
814
|
+
const imageFill = firstImageFill(node);
|
|
815
|
+
if (imageFill && imageRefMap[imageFill.imageRef]) {
|
|
816
|
+
parts.push(`background-image:url(${JSON.stringify(imageRefMap[imageFill.imageRef])})`);
|
|
817
|
+
parts.push(`background-size:${imageFill.scaleMode === "FIT" ? "contain" : "cover"}`);
|
|
818
|
+
parts.push("background-position:center");
|
|
819
|
+
parts.push("background-repeat:no-repeat");
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (node.type === "ELLIPSE") {
|
|
823
|
+
parts.push("border-radius:50%"); // circles/avatars/status dots, not square boxes
|
|
824
|
+
} else if (typeof node.cornerRadius === "number") {
|
|
825
|
+
parts.push(`border-radius:${Math.round(node.cornerRadius)}px`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (Array.isArray(node.strokes) && node.strokes.length > 0) {
|
|
829
|
+
const stroke = node.strokes.find((s) => s.visible !== false && s.type === "SOLID");
|
|
830
|
+
if (stroke) {
|
|
831
|
+
const weight = node.strokeWeight || 1;
|
|
832
|
+
parts.push(`border:${weight}px solid ${rgbaToCss(stroke.color, stroke.opacity ?? 1)}`);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (node.type === "TEXT") {
|
|
837
|
+
const style = node.style || {};
|
|
838
|
+
parts.push("white-space:pre-wrap");
|
|
839
|
+
parts.push("overflow:hidden");
|
|
840
|
+
if (style.fontFamily) parts.push(`font-family:${JSON.stringify(style.fontFamily)}, sans-serif`);
|
|
841
|
+
if (style.fontSize) parts.push(`font-size:${style.fontSize}px`);
|
|
842
|
+
if (style.fontWeight) parts.push(`font-weight:${style.fontWeight}`);
|
|
843
|
+
if (style.lineHeightPx) parts.push(`line-height:${style.lineHeightPx}px`);
|
|
844
|
+
if (style.letterSpacing) parts.push(`letter-spacing:${style.letterSpacing}px`);
|
|
845
|
+
|
|
846
|
+
const textFill = firstSolidFill(node);
|
|
847
|
+
if (textFill) parts.push(`color:${rgbaToCss(textFill.color, textFill.opacity ?? 1)}`);
|
|
848
|
+
|
|
849
|
+
const align = style.textAlignHorizontal?.toLowerCase();
|
|
850
|
+
if (align) parts.push(`text-align:${align}`);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (Array.isArray(node.effects)) {
|
|
854
|
+
const shadows = node.effects
|
|
855
|
+
.filter((effect) => effect.visible !== false && effect.type === "DROP_SHADOW")
|
|
856
|
+
.map((effect) => {
|
|
857
|
+
const x = Math.round(effect.offset?.x ?? 0);
|
|
858
|
+
const y = Math.round(effect.offset?.y ?? 0);
|
|
859
|
+
const blur = Math.round(effect.radius ?? 0);
|
|
860
|
+
const color = rgbaToCss(effect.color, effect.color?.a ?? 1);
|
|
861
|
+
return `${x}px ${y}px ${blur}px ${color}`;
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
if (shadows.length) parts.push(`box-shadow:${shadows.join(",")}`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return parts.join(";");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function classifyElement(node) {
|
|
871
|
+
if (node.type === "TEXT") return "text";
|
|
872
|
+
if (firstImageFill(node)) return "image-fill";
|
|
873
|
+
if (["RECTANGLE", "ELLIPSE", "VECTOR", "STAR", "POLYGON", "LINE"].includes(node.type)) return "shape";
|
|
874
|
+
if (["INSTANCE", "COMPONENT", "COMPONENT_SET"].includes(node.type)) return "component";
|
|
875
|
+
if (["FRAME", "GROUP", "SECTION"].includes(node.type)) return "container";
|
|
876
|
+
return "other";
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function nodeHasVisiblePaint(node) {
|
|
880
|
+
if (firstSolidFill(node) || firstImageFill(node)) return true;
|
|
881
|
+
if (Array.isArray(node.strokes) && node.strokes.some((s) => s.visible !== false && s.type === "SOLID")) return true;
|
|
882
|
+
return false;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// --- Icon library detection -------------------------------------------------
|
|
886
|
+
// Figma icons are usually instances named "<Set> / <icon-name>" (e.g. "Lucide Icons / chevron-right").
|
|
887
|
+
// We map the set to the npm package a codebase typically uses and the icon name to its PascalCase
|
|
888
|
+
// component, so the implementer imports it directly instead of redrawing the glyph as inline SVG.
|
|
889
|
+
const ICON_SETS = [
|
|
890
|
+
{ test: /^lucide(\s*icons?)?\s*[/:>-]/i, set: "lucide", pkg: "lucide-react" },
|
|
891
|
+
{ test: /^(font\s*awesome|fa)\s*[/:>-]/i, set: "font-awesome", pkg: "@fortawesome/react-fontawesome" },
|
|
892
|
+
{ test: /^feather\s*[/:>-]/i, set: "feather", pkg: "react-feather" },
|
|
893
|
+
{ test: /^(heroicons?|hero)\s*[/:>-]/i, set: "heroicons", pkg: "@heroicons/react" },
|
|
894
|
+
{ test: /^(material\s*(symbols|icons)|mui)\s*[/:>-]/i, set: "material", pkg: "@mui/icons-material" },
|
|
895
|
+
{ test: /^phosphor\s*[/:>-]/i, set: "phosphor", pkg: "@phosphor-icons/react" },
|
|
896
|
+
{ test: /^tabler\s*[/:>-]/i, set: "tabler", pkg: "@tabler/icons-react" },
|
|
897
|
+
];
|
|
898
|
+
|
|
899
|
+
function pascalFromIconName(s) {
|
|
900
|
+
return String(s).trim().split(/[^a-z0-9]+/i).filter(Boolean)
|
|
901
|
+
.map((w) => w[0].toUpperCase() + w.slice(1).toLowerCase()).join("");
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Returns { set, pkg, iconName, component, figmaName } for a known icon-set name, else null.
|
|
905
|
+
function detectIconLibrary(rawName) {
|
|
906
|
+
const name = String(rawName || "").trim();
|
|
907
|
+
if (!name) return null;
|
|
908
|
+
for (const entry of ICON_SETS) {
|
|
909
|
+
if (entry.test.test(name)) {
|
|
910
|
+
const iconName = (name.split("/").pop() || name).trim();
|
|
911
|
+
return { set: entry.set, pkg: entry.pkg, iconName, component: pascalFromIconName(iconName), figmaName: name };
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function isIconSized(b) {
|
|
918
|
+
if (!b || b.width <= 0 || b.height <= 0) return false;
|
|
919
|
+
if (b.width > 64 || b.height > 64) return false;
|
|
920
|
+
const ar = b.width / b.height;
|
|
921
|
+
return ar >= 0.4 && ar <= 2.5;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Classify a screen's nodes into: library icons (import from a package), custom icons (download SVG),
|
|
925
|
+
// and images (raster fills already downloaded to ./assets). Best-effort, never throws on bad nodes.
|
|
926
|
+
function collectScreenAssets(rootNode, nodes, componentNameById = {}, imageRefMap = {}) {
|
|
927
|
+
const libraryIcons = new Map(); // component -> { set, pkg, component, iconName, count, sizes:Set }
|
|
928
|
+
const customIcons = new Map(); // slug -> { id, name, slug, bounds }
|
|
929
|
+
const images = [];
|
|
930
|
+
const GENERIC = /^(vector|rectangle|ellipse|line|union|subtract|group|frame|polygon|star|shape|mask|image)\b/i;
|
|
931
|
+
|
|
932
|
+
for (const node of nodes) {
|
|
933
|
+
if (!node || node.id === rootNode.id) continue;
|
|
934
|
+
const b = getNodeBounds(node);
|
|
935
|
+
if (!b) continue;
|
|
936
|
+
const displayName = (node.componentId && componentNameById[node.componentId]) || node.name || "";
|
|
937
|
+
|
|
938
|
+
const imgFill = firstImageFill(node);
|
|
939
|
+
if (imgFill && imageRefMap[imgFill.imageRef]) {
|
|
940
|
+
images.push({
|
|
941
|
+
id: node.id, name: node.name || "image", assetPath: imageRefMap[imgFill.imageRef],
|
|
942
|
+
bounds: { width: Math.round(b.width), height: Math.round(b.height) },
|
|
943
|
+
});
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const lib = detectIconLibrary(displayName);
|
|
948
|
+
if (lib) {
|
|
949
|
+
const cur = libraryIcons.get(lib.component) || { ...lib, count: 0, sizes: new Set() };
|
|
950
|
+
cur.count += 1;
|
|
951
|
+
cur.sizes.add(`${Math.round(b.width)}×${Math.round(b.height)}`);
|
|
952
|
+
libraryIcons.set(lib.component, cur);
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// A custom icon is real vector/graphic content — NOT a plain RECTANGLE/ELLIPSE/LINE
|
|
957
|
+
// (those are background plates, dividers, image frames; e.g. a 57×57 white "image 18"
|
|
958
|
+
// badge plate must not be reported as an asset to supply).
|
|
959
|
+
const kind = classifyElement(node);
|
|
960
|
+
const vectorish =
|
|
961
|
+
["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON"].includes(node.type) ||
|
|
962
|
+
(kind === "component" && (!node.children || node.children.length === 0));
|
|
963
|
+
if (vectorish && isIconSized(b) && displayName && !GENERIC.test(displayName)) {
|
|
964
|
+
const slug = slugifyName(displayName);
|
|
965
|
+
if (!customIcons.has(slug)) {
|
|
966
|
+
customIcons.set(slug, {
|
|
967
|
+
id: node.id, name: displayName, slug,
|
|
968
|
+
bounds: { width: Math.round(b.width), height: Math.round(b.height) },
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
libraryIcons: [...libraryIcons.values()].map((i) => ({ ...i, sizes: [...i.sizes] })),
|
|
976
|
+
customIcons: [...customIcons.values()],
|
|
977
|
+
images,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const GENERIC_VECTOR_NAME = /^(vector|rectangle|ellipse|line|union|subtract|group|frame|polygon|star|shape|mask)\b/i;
|
|
982
|
+
|
|
983
|
+
// An "icon root" is the node we render as ONE SVG so the debug preview shows the real glyph.
|
|
984
|
+
// It's the named icon (instance/component named e.g. "Lucide Icons / chevron-right"), or an
|
|
985
|
+
// icon-sized standalone vector — NOT the inner "Vector" leaves (those would all collapse together).
|
|
986
|
+
function isIconRoot(node, componentNameById = {}) {
|
|
987
|
+
if (!node || node.type === "TEXT") return false;
|
|
988
|
+
if (firstImageFill(node)) return false; // image fills are drawn as background already
|
|
989
|
+
const name = (node.componentId && componentNameById[node.componentId]) || node.name || "";
|
|
990
|
+
const b = getNodeBounds(node);
|
|
991
|
+
if (!b) return false;
|
|
992
|
+
// Named library icon (any size) — render the whole instance.
|
|
993
|
+
if (detectIconLibrary(name)) return true;
|
|
994
|
+
// Icon-sized GRAPHIC (instance/component/vector) with a meaningful name. FRAME/GROUP are
|
|
995
|
+
// excluded on purpose — small named frames like "Container"/"Header" are layout, not icons.
|
|
996
|
+
if (isIconSized(b) && name && !GENERIC_VECTOR_NAME.test(name) &&
|
|
997
|
+
["INSTANCE", "COMPONENT", "VECTOR", "BOOLEAN_OPERATION"].includes(node.type)) {
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
// Icon-sized standalone vector with no meaningful name — still render it (keyed by id).
|
|
1001
|
+
if (isIconSized(b) && ["VECTOR", "BOOLEAN_OPERATION", "STAR", "POLYGON", "LINE"].includes(node.type)) {
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Walk the FULL tree (including hidden subtrees that flattenNodes prunes) to find icon roots
|
|
1008
|
+
// that are hidden in the resting frame — i.e. hover-state icons (the "…" / "+" that appear on
|
|
1009
|
+
// hover). We surface these dimmed in the debug preview and list them in inventory.md so the
|
|
1010
|
+
// resting frame can still be compared while knowing which elements are hover-only.
|
|
1011
|
+
function collectHiddenIconRoots(root, componentNameById = {}) {
|
|
1012
|
+
const out = [];
|
|
1013
|
+
const walk = (node, hiddenAncestor) => {
|
|
1014
|
+
if (!node) return;
|
|
1015
|
+
const selfHidden = node.visible === false || (node.opacity ?? 1) === 0;
|
|
1016
|
+
// A hover affordance is a node toggled OFF inside an otherwise-visible container.
|
|
1017
|
+
// Only collect those (selfHidden but NO hidden ancestor) — this excludes the hundreds of
|
|
1018
|
+
// deep variant-internal layers that live hidden inside component instances.
|
|
1019
|
+
if (selfHidden && !hiddenAncestor && getNodeBounds(node) && isIconRoot(node, componentNameById)) {
|
|
1020
|
+
const name = (node.componentId && componentNameById[node.componentId]) || node.name || "";
|
|
1021
|
+
const lib = detectIconLibrary(name);
|
|
1022
|
+
out.push({ id: node.id, name, lucide: lib ? lib.iconName : null, bounds: getNodeBounds(node) });
|
|
1023
|
+
}
|
|
1024
|
+
if (Array.isArray(node.children)) for (const c of node.children) walk(c, hiddenAncestor || selfHidden);
|
|
1025
|
+
};
|
|
1026
|
+
walk(root, false);
|
|
1027
|
+
return out;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Stable key so identical icons (e.g. every chevron-right instance) share ONE downloaded SVG file;
|
|
1031
|
+
// generic/unnamed vectors key by node id so they never collapse onto each other.
|
|
1032
|
+
function svgKeyFor(node, componentNameById = {}) {
|
|
1033
|
+
const name = (node.componentId && componentNameById[node.componentId]) || node.name || "";
|
|
1034
|
+
const lib = detectIconLibrary(name);
|
|
1035
|
+
if (lib) return `lib-${lib.component}`;
|
|
1036
|
+
if (name && !GENERIC_VECTOR_NAME.test(name)) return `cus-${slugifyName(name)}`;
|
|
1037
|
+
return `id-${String(node.id).replace(/[^a-z0-9]/gi, "-")}`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function shouldRenderNode(node, rootNode) {
|
|
1041
|
+
if (node.id === rootNode.id) return false;
|
|
1042
|
+
const bounds = getNodeBounds(node);
|
|
1043
|
+
if (!bounds || bounds.width <= 0 || bounds.height <= 0) return false;
|
|
1044
|
+
|
|
1045
|
+
const kind = classifyElement(node);
|
|
1046
|
+
|
|
1047
|
+
// Render visible leaf-like nodes and text.
|
|
1048
|
+
if (kind === "text" || kind === "shape" || kind === "image-fill") return true;
|
|
1049
|
+
|
|
1050
|
+
// Render component instances only if they do not have children.
|
|
1051
|
+
if (kind === "component" && (!node.children || node.children.length === 0)) return true;
|
|
1052
|
+
|
|
1053
|
+
// Render container (FRAME/GROUP/SECTION) backgrounds only when they actually paint something,
|
|
1054
|
+
// so section/card background colors and images appear without flooding the DOM with empty wrappers.
|
|
1055
|
+
// Containers come before their children in preorder, so they sit behind child content.
|
|
1056
|
+
if (kind === "container" && nodeHasVisiblePaint(node)) return true;
|
|
1057
|
+
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function escapeHtml(value = "") {
|
|
1062
|
+
return String(value)
|
|
1063
|
+
.replaceAll("&", "&")
|
|
1064
|
+
.replaceAll("<", "<")
|
|
1065
|
+
.replaceAll(">", ">")
|
|
1066
|
+
.replaceAll('"', """);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function buildHtml(rootNode, nodes, imageRefMap = {}, nodeIconMap = {}, hoverIcons = []) {
|
|
1070
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1071
|
+
const width = Math.round(rootBounds.width);
|
|
1072
|
+
const height = Math.round(rootBounds.height);
|
|
1073
|
+
|
|
1074
|
+
const rootFill = firstSolidFill(rootNode);
|
|
1075
|
+
const frameBg = rootFill ? rgbaToCss(rootFill.color, rootFill.opacity ?? 1) : "#fff";
|
|
1076
|
+
|
|
1077
|
+
// Render normal nodes plus any library-icon root (often instances-with-children that
|
|
1078
|
+
// shouldRenderNode skips) so we can drop the library glyph in via the icon library.
|
|
1079
|
+
const elements = nodes.filter(
|
|
1080
|
+
(node) => shouldRenderNode(node, rootNode) || nodeIconMap[node.id]
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
let usesLucide = false;
|
|
1084
|
+
const htmlElements = elements.map((node, index) => {
|
|
1085
|
+
const className = `node-${index}`;
|
|
1086
|
+
const text = node.type === "TEXT" ? escapeHtml(node.characters || "") : "";
|
|
1087
|
+
const label = escapeHtml(`${node.type}: ${node.name}`);
|
|
1088
|
+
// Library icons → render live from the icon library's CDN (e.g. lucide <i data-lucide>).
|
|
1089
|
+
// Custom/unknown vectors are left as boxes (their own fill color), to be supplied by hand.
|
|
1090
|
+
const icon = nodeIconMap[node.id];
|
|
1091
|
+
let inner = text;
|
|
1092
|
+
if (icon && icon.set === "lucide") {
|
|
1093
|
+
usesLucide = true;
|
|
1094
|
+
inner = `<i data-lucide="${escapeHtml(icon.name)}"></i>`;
|
|
1095
|
+
}
|
|
1096
|
+
return `<div class="figma-node ${className}" data-figma-id="${escapeHtml(node.id)}" title="${label}">${inner}</div>`;
|
|
1097
|
+
}).join("\n ");
|
|
1098
|
+
|
|
1099
|
+
const cssRules = elements.map((node, index) => {
|
|
1100
|
+
return `.node-${index}{${nodeCss(node, rootBounds, imageRefMap)}}`;
|
|
1101
|
+
}).join("\n");
|
|
1102
|
+
|
|
1103
|
+
// Hover-state icons (hidden in the resting frame) rendered dimmed + dashed so they're
|
|
1104
|
+
// distinguishable from always-visible content.
|
|
1105
|
+
const hoverHtml = hoverIcons.map((h, i) => {
|
|
1106
|
+
let inner = "";
|
|
1107
|
+
if (h.lucide) { usesLucide = true; inner = `<i data-lucide="${escapeHtml(h.lucide)}"></i>`; }
|
|
1108
|
+
return `<div class="figma-node figma-hover hovernode-${i}" data-figma-hover="true" title="HOVER: ${escapeHtml(h.name)}">${inner}</div>`;
|
|
1109
|
+
}).join("\n ");
|
|
1110
|
+
const hoverCss = hoverIcons.map((h, i) => {
|
|
1111
|
+
const b = h.bounds;
|
|
1112
|
+
return `.hovernode-${i}{position:absolute;left:${Math.round(b.x - rootBounds.x)}px;top:${Math.round(b.y - rootBounds.y)}px;width:${Math.round(b.width)}px;height:${Math.round(b.height)}px}`;
|
|
1113
|
+
}).join("\n");
|
|
1114
|
+
|
|
1115
|
+
const iconRuntime = usesLucide
|
|
1116
|
+
? `
|
|
1117
|
+
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
1118
|
+
<script>window.lucide && lucide.createIcons();</script>`
|
|
1119
|
+
: "";
|
|
1120
|
+
|
|
1121
|
+
const html = `<!doctype html>
|
|
1122
|
+
<html lang="en">
|
|
1123
|
+
<head>
|
|
1124
|
+
<meta charset="utf-8" />
|
|
1125
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1126
|
+
<title>${escapeHtml(rootNode.name)} - Figma Pixel Preview</title>
|
|
1127
|
+
<link rel="stylesheet" href="./styles.css" />
|
|
1128
|
+
</head>
|
|
1129
|
+
<body>
|
|
1130
|
+
<main class="preview-shell">
|
|
1131
|
+
<div class="toolbar">
|
|
1132
|
+
<strong>${escapeHtml(rootNode.name)}</strong>
|
|
1133
|
+
<span>${width} × ${height}</span>
|
|
1134
|
+
<span>${elements.length} rendered elements</span>
|
|
1135
|
+
</div>
|
|
1136
|
+
|
|
1137
|
+
<section class="figma-frame" data-figma-root="${escapeHtml(rootNode.id)}" data-figma-screen="${escapeHtml(rootNode.id)}">
|
|
1138
|
+
${htmlElements}
|
|
1139
|
+
${hoverHtml}
|
|
1140
|
+
</section>
|
|
1141
|
+
</main>${iconRuntime}
|
|
1142
|
+
</body>
|
|
1143
|
+
</html>`;
|
|
1144
|
+
|
|
1145
|
+
const css = `*{box-sizing:border-box}
|
|
1146
|
+
body{
|
|
1147
|
+
margin:0;
|
|
1148
|
+
font-family:Inter,Arial,sans-serif;
|
|
1149
|
+
background:#111;
|
|
1150
|
+
color:#fff;
|
|
1151
|
+
}
|
|
1152
|
+
.preview-shell{
|
|
1153
|
+
padding:24px;
|
|
1154
|
+
}
|
|
1155
|
+
.toolbar{
|
|
1156
|
+
display:flex;
|
|
1157
|
+
gap:16px;
|
|
1158
|
+
align-items:center;
|
|
1159
|
+
margin-bottom:16px;
|
|
1160
|
+
font-size:14px;
|
|
1161
|
+
color:#ddd;
|
|
1162
|
+
}
|
|
1163
|
+
.toolbar span{
|
|
1164
|
+
opacity:.75;
|
|
1165
|
+
}
|
|
1166
|
+
.figma-frame{
|
|
1167
|
+
position:relative;
|
|
1168
|
+
width:${width}px;
|
|
1169
|
+
height:${height}px;
|
|
1170
|
+
overflow:hidden;
|
|
1171
|
+
background:${frameBg};
|
|
1172
|
+
box-shadow:0 20px 80px rgba(0,0,0,.45);
|
|
1173
|
+
}
|
|
1174
|
+
.figma-node{
|
|
1175
|
+
overflow:hidden;
|
|
1176
|
+
}
|
|
1177
|
+
/* Hover-state (normally hidden) icons: dimmed + dashed so they read as "appears on hover". */
|
|
1178
|
+
.figma-hover{
|
|
1179
|
+
opacity:.45;
|
|
1180
|
+
outline:1px dashed #f59e0b;
|
|
1181
|
+
outline-offset:1px;
|
|
1182
|
+
}
|
|
1183
|
+
${hoverCss}
|
|
1184
|
+
/* Library icons rendered by the icon library fill their Figma box. */
|
|
1185
|
+
/* lucide draws with stroke:currentColor; the shell sets color:#fff, so without an
|
|
1186
|
+
explicit dark color the icons would be white-on-white (invisible "empty boxes"). */
|
|
1187
|
+
.figma-frame{
|
|
1188
|
+
color:#3f3f46;
|
|
1189
|
+
}
|
|
1190
|
+
.figma-node [data-lucide], .figma-node svg{
|
|
1191
|
+
width:100%;
|
|
1192
|
+
height:100%;
|
|
1193
|
+
display:block;
|
|
1194
|
+
color:#3f3f46;
|
|
1195
|
+
}
|
|
1196
|
+
${cssRules}
|
|
1197
|
+
`;
|
|
1198
|
+
|
|
1199
|
+
return { html, css };
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function buildInventoryMarkdown(rootNode, nodes, componentNameById = {}, hoverIcons = []) {
|
|
1203
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1204
|
+
const sorted = nodes
|
|
1205
|
+
.filter((node) => node.id !== rootNode.id && getNodeBounds(node))
|
|
1206
|
+
.map((node) => {
|
|
1207
|
+
const b = getNodeBounds(node);
|
|
1208
|
+
return {
|
|
1209
|
+
id: node.id,
|
|
1210
|
+
name: node.name,
|
|
1211
|
+
type: node.type,
|
|
1212
|
+
kind: classifyElement(node),
|
|
1213
|
+
x: Math.round(b.x - rootBounds.x),
|
|
1214
|
+
y: Math.round(b.y - rootBounds.y),
|
|
1215
|
+
width: Math.round(b.width),
|
|
1216
|
+
height: Math.round(b.height),
|
|
1217
|
+
text: node.type === "TEXT" ? node.characters || "" : "",
|
|
1218
|
+
fontSize: node.style?.fontSize ?? null,
|
|
1219
|
+
fontWeight: node.style?.fontWeight ?? null,
|
|
1220
|
+
componentName: node.componentId ? (componentNameById[node.componentId] || node.name) : null,
|
|
1221
|
+
layoutCss: autoLayoutToCss(node)?.css || null
|
|
1222
|
+
};
|
|
1223
|
+
})
|
|
1224
|
+
.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
1225
|
+
|
|
1226
|
+
const instanceCount = sorted.filter((item) => item.componentName).length;
|
|
1227
|
+
|
|
1228
|
+
const lines = [];
|
|
1229
|
+
lines.push(`# Element Inventory`);
|
|
1230
|
+
lines.push("");
|
|
1231
|
+
lines.push(`Frame: **${rootNode.name}**`);
|
|
1232
|
+
lines.push(`Node ID: \`${rootNode.id}\``);
|
|
1233
|
+
lines.push(`Size: **${Math.round(rootBounds.width)} × ${Math.round(rootBounds.height)}**`);
|
|
1234
|
+
lines.push(`Total bounded nodes: **${sorted.length}**`);
|
|
1235
|
+
if (instanceCount) lines.push(`Component instances (reuse, do not rebuild): **${instanceCount}** — see \`component-library.md\``);
|
|
1236
|
+
lines.push("");
|
|
1237
|
+
|
|
1238
|
+
for (const [index, item] of sorted.entries()) {
|
|
1239
|
+
const heading = item.componentName
|
|
1240
|
+
? `## ${index + 1}. ↻ COMPONENT INSTANCE — ${item.name}`
|
|
1241
|
+
: `## ${index + 1}. ${item.kind.toUpperCase()} — ${item.name}`;
|
|
1242
|
+
lines.push(heading);
|
|
1243
|
+
lines.push("");
|
|
1244
|
+
if (item.componentName) {
|
|
1245
|
+
lines.push(`- ↻ Shared component: \`${pascalCase(item.componentName)}\` — reuse the existing component, do NOT re-implement.`);
|
|
1246
|
+
}
|
|
1247
|
+
lines.push(`- Figma ID: \`${item.id}\``);
|
|
1248
|
+
lines.push(`- Type: \`${item.type}\``);
|
|
1249
|
+
lines.push(`- Bounds: x=${item.x}, y=${item.y}, width=${item.width}, height=${item.height}`);
|
|
1250
|
+
if (item.layoutCss) lines.push(`- Auto Layout: \`${item.layoutCss}\``);
|
|
1251
|
+
if (item.text) lines.push(`- Text: ${JSON.stringify(item.text)}`);
|
|
1252
|
+
if (item.fontSize) lines.push(`- Font size: ${item.fontSize}`);
|
|
1253
|
+
if (item.fontWeight) lines.push(`- Font weight: ${item.fontWeight}`);
|
|
1254
|
+
lines.push("");
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
// Hover / hidden-state elements — present in the design but hidden (visible:false) in the
|
|
1258
|
+
// resting frame (e.g. the row "…" / "+" that appear on hover). Listed for comparison: they
|
|
1259
|
+
// are NOT in reference.png, and show dimmed + dashed in debug-render.html.
|
|
1260
|
+
if (Array.isArray(hoverIcons) && hoverIcons.length) {
|
|
1261
|
+
const seen = new Set();
|
|
1262
|
+
const uniq = hoverIcons.filter((h) => {
|
|
1263
|
+
const k = h.lucide || h.name;
|
|
1264
|
+
if (seen.has(k)) return false;
|
|
1265
|
+
seen.add(k);
|
|
1266
|
+
return true;
|
|
1267
|
+
});
|
|
1268
|
+
lines.push("## Hover / hidden-state elements (görünmez — karşılaştırma için)", "");
|
|
1269
|
+
lines.push(
|
|
1270
|
+
"Bu öğeler tasarımda var ama durağan frame'de `visible:false` (hover'da çıkar). " +
|
|
1271
|
+
"`reference.png`'de YOKtur; `debug-render.html`'de soluk + turuncu kesik çerçeveyle gösterilir.",
|
|
1272
|
+
""
|
|
1273
|
+
);
|
|
1274
|
+
lines.push("| Element | Library icon | Count (instances) |", "|---|---|---:|");
|
|
1275
|
+
for (const h of uniq) {
|
|
1276
|
+
const count = hoverIcons.filter((x) => (x.lucide || x.name) === (h.lucide || h.name)).length;
|
|
1277
|
+
lines.push(`| ${h.name || "(unnamed)"} | ${h.lucide || "—"} | ${count} |`);
|
|
1278
|
+
}
|
|
1279
|
+
lines.push("");
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return lines.join("\n");
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Figma Auto Layout → a CSS-flexbox-shaped block the implementer can apply directly,
|
|
1286
|
+
// instead of inferring flex from absolute pixel positions.
|
|
1287
|
+
function autoLayoutToCss(node) {
|
|
1288
|
+
if (!node.layoutMode || node.layoutMode === "NONE") return null;
|
|
1289
|
+
const justify = { MIN: "flex-start", CENTER: "center", MAX: "flex-end", SPACE_BETWEEN: "space-between" };
|
|
1290
|
+
const align = { MIN: "flex-start", CENTER: "center", MAX: "flex-end", BASELINE: "baseline" };
|
|
1291
|
+
const layout = {
|
|
1292
|
+
display: "flex",
|
|
1293
|
+
flexDirection: node.layoutMode === "HORIZONTAL" ? "row" : "column",
|
|
1294
|
+
gap: Math.round(node.itemSpacing ?? 0),
|
|
1295
|
+
padding: {
|
|
1296
|
+
top: Math.round(node.paddingTop ?? 0),
|
|
1297
|
+
right: Math.round(node.paddingRight ?? 0),
|
|
1298
|
+
bottom: Math.round(node.paddingBottom ?? 0),
|
|
1299
|
+
left: Math.round(node.paddingLeft ?? 0)
|
|
1300
|
+
},
|
|
1301
|
+
justifyContent: justify[node.primaryAxisAlignItems] || "flex-start",
|
|
1302
|
+
alignItems: align[node.counterAxisAlignItems] || "flex-start",
|
|
1303
|
+
flexWrap: node.layoutWrap === "WRAP" ? "wrap" : "nowrap"
|
|
1304
|
+
};
|
|
1305
|
+
// One-line CSS the AI can paste.
|
|
1306
|
+
const p = layout.padding;
|
|
1307
|
+
const pad = (p.top === p.bottom && p.left === p.right)
|
|
1308
|
+
? (p.top === p.left ? `${p.top}px` : `${p.top}px ${p.left}px`)
|
|
1309
|
+
: `${p.top}px ${p.right}px ${p.bottom}px ${p.left}px`;
|
|
1310
|
+
layout.css = `display:flex;flex-direction:${layout.flexDirection};gap:${layout.gap}px;padding:${pad};justify-content:${layout.justifyContent};align-items:${layout.alignItems}` +
|
|
1311
|
+
(layout.flexWrap === "wrap" ? ";flex-wrap:wrap" : "");
|
|
1312
|
+
return layout;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// How a node sizes inside its auto-layout parent (FILL = stretch/flex:1, HUG = fit-content, FIXED = px).
|
|
1316
|
+
function layoutChildInfo(node) {
|
|
1317
|
+
const info = {};
|
|
1318
|
+
if (node.layoutSizingHorizontal) info.horizontal = node.layoutSizingHorizontal; // FIXED | HUG | FILL
|
|
1319
|
+
if (node.layoutSizingVertical) info.vertical = node.layoutSizingVertical;
|
|
1320
|
+
if (node.layoutGrow) info.grow = node.layoutGrow;
|
|
1321
|
+
if (node.layoutAlign && node.layoutAlign !== "INHERIT") info.align = node.layoutAlign; // STRETCH ...
|
|
1322
|
+
return Object.keys(info).length ? info : null;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function buildSpecJson(rootNode, nodes, source, componentNameById = {}, scale = REFERENCE_SCALE) {
|
|
1326
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1327
|
+
const width = Math.round(rootBounds.width);
|
|
1328
|
+
const height = Math.round(rootBounds.height);
|
|
1329
|
+
|
|
1330
|
+
return {
|
|
1331
|
+
source,
|
|
1332
|
+
frame: {
|
|
1333
|
+
id: rootNode.id,
|
|
1334
|
+
name: rootNode.name,
|
|
1335
|
+
// Logical (CSS px) size — implement against these dimensions.
|
|
1336
|
+
width,
|
|
1337
|
+
height,
|
|
1338
|
+
// reference.png is rendered at this scale, so its pixel size is width*scale × height*scale.
|
|
1339
|
+
// `compare` reads this to capture the app at the matching deviceScaleFactor.
|
|
1340
|
+
scale,
|
|
1341
|
+
exportWidth: width * scale,
|
|
1342
|
+
exportHeight: height * scale
|
|
1343
|
+
},
|
|
1344
|
+
visualTesting: {
|
|
1345
|
+
recommendedSelector: `[data-figma-screen='${rootNode.id}']`,
|
|
1346
|
+
referenceScale: scale,
|
|
1347
|
+
defaultThreshold: 0.12,
|
|
1348
|
+
defaultMaxDiffRatio: 0.03
|
|
1349
|
+
},
|
|
1350
|
+
elements: nodes
|
|
1351
|
+
.filter((node) => node.id !== rootNode.id && getNodeBounds(node))
|
|
1352
|
+
.map((node) => {
|
|
1353
|
+
const b = getNodeBounds(node);
|
|
1354
|
+
const entry = {
|
|
1355
|
+
id: node.id,
|
|
1356
|
+
name: node.name,
|
|
1357
|
+
type: node.type,
|
|
1358
|
+
kind: classifyElement(node),
|
|
1359
|
+
bounds: {
|
|
1360
|
+
x: Math.round(b.x - rootBounds.x),
|
|
1361
|
+
y: Math.round(b.y - rootBounds.y),
|
|
1362
|
+
width: Math.round(b.width),
|
|
1363
|
+
height: Math.round(b.height)
|
|
1364
|
+
},
|
|
1365
|
+
text: node.type === "TEXT" ? node.characters || "" : undefined,
|
|
1366
|
+
style: node.style || undefined,
|
|
1367
|
+
fills: node.fills || undefined,
|
|
1368
|
+
strokes: node.strokes || undefined,
|
|
1369
|
+
effects: node.effects || undefined,
|
|
1370
|
+
cornerRadius: node.cornerRadius
|
|
1371
|
+
};
|
|
1372
|
+
// Auto Layout (flex intent) — far better than guessing flex from coordinates.
|
|
1373
|
+
const layout = autoLayoutToCss(node);
|
|
1374
|
+
if (layout) entry.layout = layout;
|
|
1375
|
+
const childInfo = layoutChildInfo(node);
|
|
1376
|
+
if (childInfo) entry.layoutChild = childInfo;
|
|
1377
|
+
// Constraints (responsive pin/stretch). Mostly relevant for non-auto-layout children.
|
|
1378
|
+
if (node.constraints) entry.constraints = node.constraints;
|
|
1379
|
+
if (node.componentId) {
|
|
1380
|
+
entry.isInstance = true;
|
|
1381
|
+
entry.componentId = node.componentId;
|
|
1382
|
+
entry.componentName = componentNameById[node.componentId] || node.name;
|
|
1383
|
+
entry.suggestedComponentName = pascalCase(entry.componentName);
|
|
1384
|
+
}
|
|
1385
|
+
return entry;
|
|
1386
|
+
})
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
function buildReferencePreviewHtml(rootNode) {
|
|
1392
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1393
|
+
const width = Math.round(rootBounds.width);
|
|
1394
|
+
const height = Math.round(rootBounds.height);
|
|
1395
|
+
|
|
1396
|
+
return `<!doctype html>
|
|
1397
|
+
<html lang="en">
|
|
1398
|
+
<head>
|
|
1399
|
+
<meta charset="utf-8" />
|
|
1400
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
1401
|
+
<title>${escapeHtml(rootNode.name)} - Reference Preview</title>
|
|
1402
|
+
<style>
|
|
1403
|
+
*{box-sizing:border-box}
|
|
1404
|
+
body{margin:0;min-height:100vh;background:#111;color:#fff;font-family:Inter,Arial,sans-serif;padding:24px}
|
|
1405
|
+
.toolbar{display:flex;gap:16px;align-items:center;margin-bottom:16px;font-size:14px;color:#ddd}
|
|
1406
|
+
.toolbar span{opacity:.75}
|
|
1407
|
+
.frame{width:${width}px;height:${height}px;position:relative;overflow:hidden;background:#fff;box-shadow:0 20px 80px rgba(0,0,0,.45)}
|
|
1408
|
+
.frame img{display:block;width:${width}px;height:${height}px;object-fit:cover}
|
|
1409
|
+
</style>
|
|
1410
|
+
</head>
|
|
1411
|
+
<body>
|
|
1412
|
+
<div class="toolbar">
|
|
1413
|
+
<strong>${escapeHtml(rootNode.name)}</strong>
|
|
1414
|
+
<span>${width} × ${height}</span>
|
|
1415
|
+
<span>Pixel-perfect reference preview</span>
|
|
1416
|
+
</div>
|
|
1417
|
+
<main class="frame" data-figma-root="${escapeHtml(rootNode.id)}" data-figma-screen="${escapeHtml(rootNode.id)}">
|
|
1418
|
+
<img src="./reference.png" alt="${escapeHtml(rootNode.name)} Figma reference" />
|
|
1419
|
+
</main>
|
|
1420
|
+
</body>
|
|
1421
|
+
</html>`;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function summarizeNodeTypes(nodes, rootNode) {
|
|
1425
|
+
const summary = new Map();
|
|
1426
|
+
const rendered = new Map();
|
|
1427
|
+
const skipped = new Map();
|
|
1428
|
+
|
|
1429
|
+
for (const node of nodes) {
|
|
1430
|
+
const type = node.type || "UNKNOWN";
|
|
1431
|
+
summary.set(type, (summary.get(type) || 0) + 1);
|
|
1432
|
+
|
|
1433
|
+
if (node.id !== rootNode.id && getNodeBounds(node)) {
|
|
1434
|
+
if (shouldRenderNode(node, rootNode)) rendered.set(type, (rendered.get(type) || 0) + 1);
|
|
1435
|
+
else skipped.set(type, (skipped.get(type) || 0) + 1);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const toObject = (map) => Object.fromEntries([...map.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
|
|
1440
|
+
return {
|
|
1441
|
+
totalNodes: nodes.length,
|
|
1442
|
+
boundedNodes: nodes.filter((node) => getNodeBounds(node)).length,
|
|
1443
|
+
byType: toObject(summary),
|
|
1444
|
+
renderedByCurrentHtmlRenderer: toObject(rendered),
|
|
1445
|
+
skippedByCurrentHtmlRenderer: toObject(skipped)
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function buildInspectMarkdown(rootNode, nodes, source) {
|
|
1450
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1451
|
+
const summary = summarizeNodeTypes(nodes, rootNode);
|
|
1452
|
+
const lines = [];
|
|
1453
|
+
lines.push("# Figma Inspect Report", "");
|
|
1454
|
+
lines.push(`Source: ${source.figmaUrl}`);
|
|
1455
|
+
lines.push(`Frame: **${rootNode.name}**`);
|
|
1456
|
+
lines.push(`Node ID: \`${rootNode.id}\``);
|
|
1457
|
+
lines.push(`Size: **${Math.round(rootBounds.width)} × ${Math.round(rootBounds.height)}**`, "");
|
|
1458
|
+
lines.push("## Summary", "");
|
|
1459
|
+
lines.push(`- Total nodes: **${summary.totalNodes}**`);
|
|
1460
|
+
lines.push(`- Bounded nodes: **${summary.boundedNodes}**`, "");
|
|
1461
|
+
lines.push("## Node types", "");
|
|
1462
|
+
for (const [type, count] of Object.entries(summary.byType)) lines.push(`- ${type}: ${count}`);
|
|
1463
|
+
lines.push("", "## Rendered by current debug HTML renderer", "");
|
|
1464
|
+
for (const [type, count] of Object.entries(summary.renderedByCurrentHtmlRenderer)) lines.push(`- ${type}: ${count}`);
|
|
1465
|
+
lines.push("", "## Skipped by current debug HTML renderer", "");
|
|
1466
|
+
for (const [type, count] of Object.entries(summary.skippedByCurrentHtmlRenderer)) lines.push(`- ${type}: ${count}`);
|
|
1467
|
+
lines.push("", "## Important note", "");
|
|
1468
|
+
lines.push("`index.html` uses the Figma-exported `reference.png` for a pixel-perfect preview.");
|
|
1469
|
+
lines.push("`debug-render.html` is the experimental node-to-div renderer. It now draws container/frame background fills, strokes and image fills (downloaded to `assets/`), but remains approximate for fonts and vector/icon geometry.", "");
|
|
1470
|
+
return lines.join("\n");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
async function getFigmaRootNode(figmaUrl, token) {
|
|
1474
|
+
const source = parseFigmaUrl(figmaUrl);
|
|
1475
|
+
const nodeUrl = new URL(`https://api.figma.com/v1/files/${source.fileKey}/nodes`);
|
|
1476
|
+
nodeUrl.searchParams.set("ids", source.nodeId);
|
|
1477
|
+
console.log(`Fetching node JSON: ${source.fileKey} / ${source.nodeId}`);
|
|
1478
|
+
const nodeResponse = await figmaFetch(nodeUrl.toString(), token);
|
|
1479
|
+
const entry = nodeResponse.nodes?.[source.nodeId];
|
|
1480
|
+
const rootNode = entry?.document;
|
|
1481
|
+
if (!rootNode) throw new Error(`Could not find node ${source.nodeId} in Figma response.`);
|
|
1482
|
+
// The /nodes endpoint nests component metadata per requested node.
|
|
1483
|
+
const componentsMeta = entry.components || {};
|
|
1484
|
+
const componentSetsMeta = entry.componentSets || {};
|
|
1485
|
+
return { source, nodeResponse, rootNode, componentsMeta, componentSetsMeta };
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async function inspectCommand(figmaUrl) {
|
|
1489
|
+
if (!figmaUrl) throw new Error("Missing Figma URL.");
|
|
1490
|
+
const token = await resolveFigmaToken();
|
|
1491
|
+
if (!token) throw new Error('Missing Figma token. Run: figma-pixel-kit token "figd_xxxxxxxxx"');
|
|
1492
|
+
|
|
1493
|
+
const { rootNode } = await getFigmaRootNode(figmaUrl, token);
|
|
1494
|
+
const allNodes = flattenNodes(rootNode);
|
|
1495
|
+
const summary = summarizeNodeTypes(allNodes, rootNode);
|
|
1496
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1497
|
+
|
|
1498
|
+
console.log("");
|
|
1499
|
+
console.log("Inspect summary");
|
|
1500
|
+
console.log("---------------");
|
|
1501
|
+
console.log(`Frame: ${rootNode.name}`);
|
|
1502
|
+
console.log(`Node ID: ${rootNode.id}`);
|
|
1503
|
+
console.log(`Size: ${Math.round(rootBounds.width)} × ${Math.round(rootBounds.height)}`);
|
|
1504
|
+
console.log(`Total nodes: ${summary.totalNodes}`);
|
|
1505
|
+
console.log(`Bounded nodes: ${summary.boundedNodes}`);
|
|
1506
|
+
console.log("");
|
|
1507
|
+
console.log("Node types:");
|
|
1508
|
+
for (const [type, count] of Object.entries(summary.byType)) console.log(` ${type}: ${count}`);
|
|
1509
|
+
console.log("");
|
|
1510
|
+
console.log("Rendered by debug HTML renderer:");
|
|
1511
|
+
for (const [type, count] of Object.entries(summary.renderedByCurrentHtmlRenderer)) console.log(` ${type}: ${count}`);
|
|
1512
|
+
console.log("");
|
|
1513
|
+
console.log("Skipped by debug HTML renderer:");
|
|
1514
|
+
for (const [type, count] of Object.entries(summary.skippedByCurrentHtmlRenderer)) console.log(` ${type}: ${count}`);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function buildAssetsMarkdown(assets, rootNode) {
|
|
1518
|
+
const a = assets || { libraryIcons: [], customIcons: [], images: [] };
|
|
1519
|
+
const lines = [];
|
|
1520
|
+
lines.push(`# Icons & Assets — ${rootNode.name}`, "");
|
|
1521
|
+
lines.push(
|
|
1522
|
+
"Implement icons/images from here instead of redrawing them.",
|
|
1523
|
+
"- **Library icons** → import from the project's icon package (map to its equivalent if it differs). In the debug preview these render live from the library CDN.",
|
|
1524
|
+
"- **Custom icons** → NOT downloaded. They appear as a placeholder box (their own fill color) in the preview — export/supply the asset yourself.",
|
|
1525
|
+
"- **Images** → reference the file in `assets/` (`<img>`/background), do not re-create.",
|
|
1526
|
+
""
|
|
1527
|
+
);
|
|
1528
|
+
|
|
1529
|
+
lines.push("## Library icons", "");
|
|
1530
|
+
if (a.libraryIcons.length) {
|
|
1531
|
+
lines.push("| Icon | Set | Suggested import | Count | Sizes |", "|---|---|---|---:|---|");
|
|
1532
|
+
for (const i of a.libraryIcons) {
|
|
1533
|
+
lines.push(`| ${i.component} | ${i.set} | \`import { ${i.component} } from "${i.pkg}"\` | ${i.count} | ${i.sizes.join(", ")} |`);
|
|
1534
|
+
}
|
|
1535
|
+
} else lines.push("_None detected._");
|
|
1536
|
+
|
|
1537
|
+
lines.push("", "## Custom icons (placeholder box — supply your own asset)", "");
|
|
1538
|
+
if (a.customIcons.length) {
|
|
1539
|
+
lines.push("| Name | Size | Note |", "|---|---|---|");
|
|
1540
|
+
for (const c of a.customIcons) {
|
|
1541
|
+
lines.push(`| ${c.name} | ${c.bounds.width}×${c.bounds.height} | not a known library — export from Figma and reference under \`assets/\` |`);
|
|
1542
|
+
}
|
|
1543
|
+
} else lines.push("_None detected._");
|
|
1544
|
+
|
|
1545
|
+
lines.push("", "## Images", "");
|
|
1546
|
+
if (a.images.length) {
|
|
1547
|
+
lines.push("| Name | Asset | Size |", "|---|---|---|");
|
|
1548
|
+
for (const im of a.images) {
|
|
1549
|
+
lines.push(`| ${im.name} | \`${im.assetPath}\` | ${im.bounds.width}×${im.bounds.height} |`);
|
|
1550
|
+
}
|
|
1551
|
+
} else lines.push("_None detected._");
|
|
1552
|
+
|
|
1553
|
+
lines.push("");
|
|
1554
|
+
return lines.join("\n");
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
function buildClaudePrompt(rootNode, assets) {
|
|
1558
|
+
const a = assets || { libraryIcons: [], customIcons: [], images: [] };
|
|
1559
|
+
const libList = a.libraryIcons.slice(0, 24).map((i) => i.component).join(", ");
|
|
1560
|
+
const iconLine = a.libraryIcons.length
|
|
1561
|
+
? `${a.libraryIcons.length} library icon(s) detected (${libList}${a.libraryIcons.length > 24 ? ", …" : ""}). Import each from the project's icon package (e.g. \`${a.libraryIcons[0].pkg}\`) — see \`assets.md\`. Do NOT redraw glyphs as inline SVG/paths.`
|
|
1562
|
+
: "No known-library icons auto-detected; check `assets.md` for custom SVGs.";
|
|
1563
|
+
const customLine = a.customIcons.length
|
|
1564
|
+
? ` ${a.customIcons.length} custom (non-library) icon(s) appear as placeholder boxes — export/supply those assets yourself.`
|
|
1565
|
+
: "";
|
|
1566
|
+
const imageLine = a.images.length
|
|
1567
|
+
? `${a.images.length} image(s) downloaded to \`assets/\` — reference them (\`<img>\`/background); do not re-create.`
|
|
1568
|
+
: "No raster images detected for this screen.";
|
|
1569
|
+
|
|
1570
|
+
return `# Figma Implementation Task
|
|
1571
|
+
|
|
1572
|
+
You are implementing a read-only Figma design in an existing frontend project.
|
|
1573
|
+
|
|
1574
|
+
## Source files
|
|
1575
|
+
|
|
1576
|
+
Use these files from this folder:
|
|
1577
|
+
|
|
1578
|
+
- \`reference.png\` — visual truth exported from Figma
|
|
1579
|
+
- \`index.html\` — absolute-positioned pixel preview draft
|
|
1580
|
+
- \`styles.css\` — CSS generated from Figma node bounds/styles
|
|
1581
|
+
- \`inventory.md\` — complete element checklist
|
|
1582
|
+
- \`spec.json\` — structured element data (elements with \`isInstance:true\` are shared components — reuse, do not rebuild)
|
|
1583
|
+
- \`component-library.md\` — shared components for this screen/board (build once, reuse). Present only when instances exist.
|
|
1584
|
+
- \`assets.md\` — icons (library imports + custom SVGs) and images for this screen
|
|
1585
|
+
- \`figma-node.json\` — original Figma node response
|
|
1586
|
+
- \`visual-test.spec.ts\` — optional Playwright test template
|
|
1587
|
+
|
|
1588
|
+
## Figma frame
|
|
1589
|
+
|
|
1590
|
+
- Name: ${rootNode.name}
|
|
1591
|
+
- Node ID: ${rootNode.id}
|
|
1592
|
+
|
|
1593
|
+
## Icons, images & reuse
|
|
1594
|
+
|
|
1595
|
+
- Icons: ${iconLine}${customLine}
|
|
1596
|
+
- Images: ${imageLine}
|
|
1597
|
+
- Reuse: elements with \`isInstance:true\` in \`spec.json\` (and \`component-library.md\`) are SHARED design-system components — implement each ONCE as a reusable component and import it on every screen. Do NOT re-code shared chrome (left nav/sidebar, header, breadcrumb, toolbar) per screen; build it once and reuse. Prefer the project's existing components over new primitives.
|
|
1598
|
+
|
|
1599
|
+
## Hard rules
|
|
1600
|
+
|
|
1601
|
+
1. Do not redesign the screen.
|
|
1602
|
+
2. Do not simplify the UI.
|
|
1603
|
+
3. Do not skip visible elements.
|
|
1604
|
+
4. Before coding, read \`inventory.md\`.
|
|
1605
|
+
5. Every visible element in \`inventory.md\` must be represented in JSX/CSS or explicitly listed as intentionally omitted.
|
|
1606
|
+
6. Use \`reference.png\` as the visual source of truth.
|
|
1607
|
+
7. Use \`index.html\` only as a layout/positioning reference, not as production code.
|
|
1608
|
+
8. Convert absolute positioning to semantic flex/grid where possible, but preserve the visual composition. When an element in \`spec.json\` has a \`layout\` block (parsed Figma Auto Layout), implement it with exactly those flex properties (\`layout.css\` is ready to paste) instead of guessing from coordinates; honor \`layoutChild\` (FILL→flex:1/stretch, HUG→fit-content, FIXED→px) and \`constraints\` for responsive pin/stretch.
|
|
1609
|
+
9. Do not create duplicate primitive components if the project already has Button, Card, Badge, Input, Modal, Container, etc. Elements marked \`isInstance\` in \`spec.json\` (or listed in \`component-library.md\`) are shared Figma components — implement each as ONE reusable component and reuse it; never copy-paste the same markup per screen.
|
|
1610
|
+
10. Do not refactor unrelated files.
|
|
1611
|
+
11. Work section by section.
|
|
1612
|
+
12. Add a stable selector to the implemented root element, for example: \`data-figma-screen="${rootNode.id}"\`.
|
|
1613
|
+
13. The element carrying that selector MUST render at the exact frame size in \`spec.json\` (\`frame.width\` × \`frame.height\`) when captured. If your layout is responsive, set the capture container to that fixed width — otherwise the visual diff compares mismatched sizes and is meaningless.
|
|
1614
|
+
14. After coding, run visual comparison with \`figma-pixel-kit compare\`.
|
|
1615
|
+
15. If visual diff is above threshold, fix spacing, typography, dimensions, colors and missing elements only.
|
|
1616
|
+
16. Preview artifacts (\`index.html\`, \`debug-render.html\`, \`styles.css\`) are references for you — do NOT import or ship them. (\`assets/\` IS shippable: real images and custom icon SVGs live there.)
|
|
1617
|
+
17. Icons: for every icon in \`assets.md\` "Library icons", import the named component from the project's icon package (map to the project's equivalent package if it differs) — never hand-draw it as inline SVG. "Custom icons" are NOT downloaded (they show as placeholder boxes) — export them from Figma and reference under \`assets/\`.
|
|
1618
|
+
18. Images: reference files from \`assets/\` (do not re-create or inline base64).
|
|
1619
|
+
19. Reuse: build each shared component (especially left nav/sidebar, header, breadcrumb, toolbar) ONCE and import it on every screen — never re-code shared chrome per screen.
|
|
1620
|
+
20. After coding, produce a checklist table:
|
|
1621
|
+
|
|
1622
|
+
| Figma element | Implemented? | File/component | Notes |
|
|
1623
|
+
|---|---:|---|---|
|
|
1624
|
+
|
|
1625
|
+
## Visual QA command
|
|
1626
|
+
|
|
1627
|
+
After the page is implemented and your dev server is running, run:
|
|
1628
|
+
|
|
1629
|
+
\`\`\`bash
|
|
1630
|
+
figma-pixel-kit compare "<this-output-folder>" --url "http://localhost:3000" --selector "[data-figma-screen='${rootNode.id}']"
|
|
1631
|
+
\`\`\`
|
|
1632
|
+
|
|
1633
|
+
If the implementation is page-level and no selector is available yet, run without \`--selector\`.
|
|
1634
|
+
Text-heavy screen failing only on font rendering? Add \`--ignore-text\` to focus the diff on layout/color/spacing.
|
|
1635
|
+
|
|
1636
|
+
## Task
|
|
1637
|
+
|
|
1638
|
+
Implement this screen/section in the existing project using the project stack and components.
|
|
1639
|
+
`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function buildVisualTest(rootNode, outDirName, scale = REFERENCE_SCALE) {
|
|
1643
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
1644
|
+
const width = Math.round(rootBounds.width);
|
|
1645
|
+
const height = Math.round(rootBounds.height);
|
|
1646
|
+
const selector = `[data-figma-screen='${rootNode.id}']`;
|
|
1647
|
+
|
|
1648
|
+
return `import { test, expect } from "@playwright/test";
|
|
1649
|
+
|
|
1650
|
+
// ⚠ WHAT THIS TEST DOES — READ BEFORE TRUSTING IT
|
|
1651
|
+
// This is a SELF-baseline regression test. Playwright's toHaveScreenshot() captures the page
|
|
1652
|
+
// on the FIRST run, stores it under its own __snapshots__ folder, and on later runs compares
|
|
1653
|
+
// against THAT self-made image — NOT against the Figma reference.png in this folder.
|
|
1654
|
+
//
|
|
1655
|
+
// => Passing here means "the page has not changed since the first run".
|
|
1656
|
+
// => It does NOT mean "the page matches the Figma design".
|
|
1657
|
+
//
|
|
1658
|
+
// For real Figma validation (diff against reference.png), use the CLI instead:
|
|
1659
|
+
// figma-pixel-kit compare "${outDirName}" --url "http://localhost:3000" --selector "${selector}"
|
|
1660
|
+
//
|
|
1661
|
+
// The viewport + deviceScaleFactor below match the reference export scale so, if you DO replace the
|
|
1662
|
+
// generated baseline with reference.png, the pixel dimensions line up.
|
|
1663
|
+
|
|
1664
|
+
test.use({
|
|
1665
|
+
viewport: { width: ${width}, height: ${height} },
|
|
1666
|
+
deviceScaleFactor: ${scale}
|
|
1667
|
+
});
|
|
1668
|
+
|
|
1669
|
+
test("${rootNode.name} self-baseline regression", async ({ page }) => {
|
|
1670
|
+
await page.goto("http://localhost:3000");
|
|
1671
|
+
// "load" avoids hangs on dev servers with long-lived sockets (HMR); switch to "networkidle" if needed.
|
|
1672
|
+
await page.waitForLoadState("load");
|
|
1673
|
+
|
|
1674
|
+
const target = page.locator(${JSON.stringify(selector)});
|
|
1675
|
+
|
|
1676
|
+
await expect(target).toHaveScreenshot("${outDirName}.png", {
|
|
1677
|
+
threshold: 0.12,
|
|
1678
|
+
maxDiffPixelRatio: 0.03,
|
|
1679
|
+
animations: "disabled"
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
`;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function findDirByName(baseDir, name, depth = 4) {
|
|
1686
|
+
// Breadth-limited recursive search for a directory named `name` under baseDir.
|
|
1687
|
+
const matches = [];
|
|
1688
|
+
const walk = (dir, remaining) => {
|
|
1689
|
+
if (remaining < 0) return;
|
|
1690
|
+
let entries;
|
|
1691
|
+
try {
|
|
1692
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1693
|
+
} catch {
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
for (const entry of entries) {
|
|
1697
|
+
if (!entry.isDirectory()) continue;
|
|
1698
|
+
const full = path.join(dir, entry.name);
|
|
1699
|
+
if (entry.name === name) matches.push(full);
|
|
1700
|
+
else walk(full, remaining - 1);
|
|
1701
|
+
}
|
|
1702
|
+
};
|
|
1703
|
+
walk(baseDir, depth);
|
|
1704
|
+
return matches;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function resolveOutputDir(input) {
|
|
1708
|
+
if (!input) throw new Error("Missing output folder/screen id.");
|
|
1709
|
+
|
|
1710
|
+
const direct = path.resolve(ROOT, input);
|
|
1711
|
+
if (existsSync(direct) && statSync(direct).isDirectory()) return direct;
|
|
1712
|
+
|
|
1713
|
+
const underDesignAi = path.resolve(ROOT, "design-ai", input);
|
|
1714
|
+
if (existsSync(underDesignAi) && statSync(underDesignAi).isDirectory()) return underDesignAi;
|
|
1715
|
+
|
|
1716
|
+
// Board screens live at design-ai/<board>/screens/<slug>; resolve by bare slug too.
|
|
1717
|
+
const designAiRoot = path.resolve(ROOT, "design-ai");
|
|
1718
|
+
if (existsSync(designAiRoot)) {
|
|
1719
|
+
const matches = findDirByName(designAiRoot, path.basename(input));
|
|
1720
|
+
if (matches.length === 1) return matches[0];
|
|
1721
|
+
if (matches.length > 1) {
|
|
1722
|
+
throw new Error(`Ambiguous screen id "${input}" matches multiple folders:\n${matches.map((m) => ` ${path.relative(ROOT, m)}`).join("\n")}\nPass the full design-ai-relative path instead.`);
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
throw new Error(`Output folder not found: ${input}`);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async function readJson(filePath) {
|
|
1730
|
+
return JSON.parse(await readFile(filePath, "utf8"));
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async function readPng(filePath) {
|
|
1734
|
+
const buffer = await readFile(filePath);
|
|
1735
|
+
return PNG.sync.read(buffer);
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
async function writePng(filePath, png) {
|
|
1739
|
+
await writeFile(filePath, PNG.sync.write(png));
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function normalizePng(source, width, height, background = { r: 255, g: 255, b: 255, a: 255 }) {
|
|
1743
|
+
const target = new PNG({ width, height });
|
|
1744
|
+
|
|
1745
|
+
for (let y = 0; y < height; y++) {
|
|
1746
|
+
for (let x = 0; x < width; x++) {
|
|
1747
|
+
const idx = (width * y + x) << 2;
|
|
1748
|
+
target.data[idx] = background.r;
|
|
1749
|
+
target.data[idx + 1] = background.g;
|
|
1750
|
+
target.data[idx + 2] = background.b;
|
|
1751
|
+
target.data[idx + 3] = background.a;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
const copyWidth = Math.min(width, source.width);
|
|
1756
|
+
const copyHeight = Math.min(height, source.height);
|
|
1757
|
+
|
|
1758
|
+
for (let y = 0; y < copyHeight; y++) {
|
|
1759
|
+
for (let x = 0; x < copyWidth; x++) {
|
|
1760
|
+
const sourceIdx = (source.width * y + x) << 2;
|
|
1761
|
+
const targetIdx = (width * y + x) << 2;
|
|
1762
|
+
target.data[targetIdx] = source.data[sourceIdx];
|
|
1763
|
+
target.data[targetIdx + 1] = source.data[sourceIdx + 1];
|
|
1764
|
+
target.data[targetIdx + 2] = source.data[sourceIdx + 2];
|
|
1765
|
+
target.data[targetIdx + 3] = source.data[sourceIdx + 3];
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
return target;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// Paint the given rectangles a uniform color in-place (used by --ignore-text to neutralize
|
|
1773
|
+
// text regions identically in both images so font rendering differences don't count as diff).
|
|
1774
|
+
function maskRegions(png, rects, color = { r: 128, g: 128, b: 128, a: 255 }) {
|
|
1775
|
+
for (const rect of rects) {
|
|
1776
|
+
const x0 = Math.max(0, Math.floor(rect.x));
|
|
1777
|
+
const y0 = Math.max(0, Math.floor(rect.y));
|
|
1778
|
+
const x1 = Math.min(png.width, Math.ceil(rect.x + rect.width));
|
|
1779
|
+
const y1 = Math.min(png.height, Math.ceil(rect.y + rect.height));
|
|
1780
|
+
for (let y = y0; y < y1; y++) {
|
|
1781
|
+
for (let x = x0; x < x1; x++) {
|
|
1782
|
+
const idx = (png.width * y + x) << 2;
|
|
1783
|
+
png.data[idx] = color.r;
|
|
1784
|
+
png.data[idx + 1] = color.g;
|
|
1785
|
+
png.data[idx + 2] = color.b;
|
|
1786
|
+
png.data[idx + 3] = color.a;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
return png;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Text element bounds (logical px) scaled into the device-px space of the compared rasters.
|
|
1794
|
+
function textMaskRects(spec, scale) {
|
|
1795
|
+
return (spec.elements || [])
|
|
1796
|
+
.filter((e) => (e.kind === "text" || e.type === "TEXT") && e.bounds)
|
|
1797
|
+
.map((e) => ({
|
|
1798
|
+
x: e.bounds.x * scale,
|
|
1799
|
+
y: e.bounds.y * scale,
|
|
1800
|
+
width: e.bounds.width * scale,
|
|
1801
|
+
height: e.bounds.height * scale
|
|
1802
|
+
}));
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
function detectHotspots(diffPng, gridColumns = 4, gridRows = 4, scale = 1) {
|
|
1806
|
+
const cells = [];
|
|
1807
|
+
const cellWidth = Math.ceil(diffPng.width / gridColumns);
|
|
1808
|
+
const cellHeight = Math.ceil(diffPng.height / gridRows);
|
|
1809
|
+
|
|
1810
|
+
for (let row = 0; row < gridRows; row++) {
|
|
1811
|
+
for (let col = 0; col < gridColumns; col++) {
|
|
1812
|
+
cells.push({
|
|
1813
|
+
row,
|
|
1814
|
+
col,
|
|
1815
|
+
x: col * cellWidth,
|
|
1816
|
+
y: row * cellHeight,
|
|
1817
|
+
width: Math.min(cellWidth, diffPng.width - col * cellWidth),
|
|
1818
|
+
height: Math.min(cellHeight, diffPng.height - row * cellHeight),
|
|
1819
|
+
diffPixels: 0
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
for (let y = 0; y < diffPng.height; y++) {
|
|
1825
|
+
for (let x = 0; x < diffPng.width; x++) {
|
|
1826
|
+
const idx = (diffPng.width * y + x) << 2;
|
|
1827
|
+
const r = diffPng.data[idx];
|
|
1828
|
+
const g = diffPng.data[idx + 1];
|
|
1829
|
+
const b = diffPng.data[idx + 2];
|
|
1830
|
+
const a = diffPng.data[idx + 3];
|
|
1831
|
+
|
|
1832
|
+
// Pixelmatch writes diff pixels as colored values. Treat non-white opaque pixels as hotspots.
|
|
1833
|
+
const isDifferent = a > 0 && !(r > 245 && g > 245 && b > 245);
|
|
1834
|
+
if (!isDifferent) continue;
|
|
1835
|
+
|
|
1836
|
+
const col = Math.min(gridColumns - 1, Math.floor(x / cellWidth));
|
|
1837
|
+
const row = Math.min(gridRows - 1, Math.floor(y / cellHeight));
|
|
1838
|
+
cells[row * gridColumns + col].diffPixels++;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
const s = scale > 0 ? scale : 1;
|
|
1843
|
+
return cells
|
|
1844
|
+
.filter((cell) => cell.diffPixels > 0)
|
|
1845
|
+
.sort((a, b) => b.diffPixels - a.diffPixels)
|
|
1846
|
+
.slice(0, 8)
|
|
1847
|
+
// Report coordinates in logical/design px (divide out the reference scale) so they map to CSS.
|
|
1848
|
+
.map((cell) => ({
|
|
1849
|
+
x: Math.round(cell.x / s),
|
|
1850
|
+
y: Math.round(cell.y / s),
|
|
1851
|
+
width: Math.round(cell.width / s),
|
|
1852
|
+
height: Math.round(cell.height / s),
|
|
1853
|
+
diffPixels: cell.diffPixels
|
|
1854
|
+
}));
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function buildFixPrompt({ report, outputDir }) {
|
|
1858
|
+
const hotspots = report.hotspots.length
|
|
1859
|
+
? report.hotspots.map((spot, index) => `${index + 1}. x=${spot.x}, y=${spot.y}, width=${spot.width}, height=${spot.height}, diffPixels=${spot.diffPixels}`).join("\n")
|
|
1860
|
+
: "No major hotspot detected.";
|
|
1861
|
+
|
|
1862
|
+
return `# Visual Fix Task
|
|
1863
|
+
|
|
1864
|
+
The implementation was compared against the Figma reference.
|
|
1865
|
+
|
|
1866
|
+
## Files
|
|
1867
|
+
|
|
1868
|
+
- Reference: \`${path.relative(outputDir, path.join(outputDir, "visual-report", "reference.png"))}\`
|
|
1869
|
+
- Actual: \`${path.relative(outputDir, path.join(outputDir, "visual-report", "actual.png"))}\`
|
|
1870
|
+
- Diff: \`${path.relative(outputDir, path.join(outputDir, "visual-report", "diff.png"))}\`
|
|
1871
|
+
- Report: \`${path.relative(outputDir, path.join(outputDir, "visual-report", "report.json"))}\`
|
|
1872
|
+
|
|
1873
|
+
## Result
|
|
1874
|
+
|
|
1875
|
+
- Status: **${report.passed ? "PASS" : "FAIL"}**
|
|
1876
|
+
- Diff ratio: **${(report.diffRatio * 100).toFixed(3)}%**
|
|
1877
|
+
- Max allowed diff ratio: **${(report.maxDiffRatio * 100).toFixed(3)}%**
|
|
1878
|
+
- Diff pixels: **${report.diffPixels} / ${report.totalPixels}**
|
|
1879
|
+
- Reference size: **${report.reference.width} × ${report.reference.height}** px (exported at ${report.designScale ?? 1}x)
|
|
1880
|
+
- Actual size: **${report.actual.width} × ${report.actual.height}** px
|
|
1881
|
+
${report.dimensionMismatch ? "- ⚠ **Size mismatch**: reference and actual differ in pixel size. Fix this FIRST — the diff is padded with white in the non-overlapping area and the ratio is only partially meaningful. Check the captured element/selector size or root container dimensions." : ""}
|
|
1882
|
+
${report.ignoreText ? `- ℹ **Text ignored**: ${report.textRegionsMasked} text region(s) were masked out (\`--ignore-text\`). This diff reflects layout/color/spacing only — text content/typography is NOT validated here.` : ""}
|
|
1883
|
+
|
|
1884
|
+
## Diff hotspots
|
|
1885
|
+
|
|
1886
|
+
Coordinates are in logical/design px (CSS px), top-left origin.
|
|
1887
|
+
|
|
1888
|
+
${hotspots}
|
|
1889
|
+
|
|
1890
|
+
## Fix rules
|
|
1891
|
+
|
|
1892
|
+
1. Fix only visual differences.
|
|
1893
|
+
2. Do not refactor unrelated code.
|
|
1894
|
+
3. Do not redesign.
|
|
1895
|
+
4. Do not remove elements that exist in \`inventory.md\`.
|
|
1896
|
+
5. Prefer small changes to spacing, sizing, typography, colors, borders, shadows and asset placement.
|
|
1897
|
+
6. After changes, run the same compare command again.
|
|
1898
|
+
7. If actual size differs from reference size, fix root container width/height or selector capture first.
|
|
1899
|
+
|
|
1900
|
+
## Task
|
|
1901
|
+
|
|
1902
|
+
Use \`reference.png\`, \`actual.png\`, and \`diff.png\` to reduce the visual diff below the configured threshold.
|
|
1903
|
+
`;
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function numOr(value, fallback) {
|
|
1907
|
+
if (value === undefined || value === true) return fallback;
|
|
1908
|
+
const n = Number(value);
|
|
1909
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async function compareCommand(input, options) {
|
|
1913
|
+
const outputDir = resolveOutputDir(input);
|
|
1914
|
+
const specPath = path.join(outputDir, "spec.json");
|
|
1915
|
+
const referencePath = path.join(outputDir, "reference.png");
|
|
1916
|
+
|
|
1917
|
+
if (!existsSync(specPath)) throw new Error(`Missing spec.json in ${outputDir}`);
|
|
1918
|
+
if (!existsSync(referencePath)) throw new Error(`Missing reference.png in ${outputDir}`);
|
|
1919
|
+
|
|
1920
|
+
const targetUrl = options.url;
|
|
1921
|
+
if (!targetUrl || targetUrl === true) {
|
|
1922
|
+
throw new Error("Missing --url. Example: --url http://localhost:3000");
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
const spec = await readJson(specPath);
|
|
1926
|
+
const width = numOr(spec.frame?.width, 1440);
|
|
1927
|
+
const height = numOr(spec.frame?.height, 900);
|
|
1928
|
+
// Capture at the same scale the reference.png was exported with, so pixelmatch compares like-for-like.
|
|
1929
|
+
// Older specs without a scale field default to 1 (matching their 1x reference).
|
|
1930
|
+
const scale = numOr(spec.frame?.scale ?? spec.visualTesting?.referenceScale, 1) || 1;
|
|
1931
|
+
const selector = typeof options.selector === "string" ? options.selector : null;
|
|
1932
|
+
const threshold = numOr(options.threshold ?? spec.visualTesting?.defaultThreshold, 0.12);
|
|
1933
|
+
const maxDiffRatio = numOr(options["max-diff-ratio"] ?? spec.visualTesting?.defaultMaxDiffRatio, 0.03);
|
|
1934
|
+
const waitMs = numOr(options.wait, 1000);
|
|
1935
|
+
const fullPage = Boolean(options["full-page"]);
|
|
1936
|
+
const waitUntil = typeof options["wait-until"] === "string" ? options["wait-until"] : "load";
|
|
1937
|
+
const ignoreText = Boolean(options["ignore-text"]);
|
|
1938
|
+
|
|
1939
|
+
const reportDir = path.join(outputDir, "visual-report");
|
|
1940
|
+
await mkdir(reportDir, { recursive: true });
|
|
1941
|
+
|
|
1942
|
+
const actualPath = path.join(reportDir, "actual.png");
|
|
1943
|
+
const reportReferencePath = path.join(reportDir, "reference.png");
|
|
1944
|
+
const diffPath = path.join(reportDir, "diff.png");
|
|
1945
|
+
const reportPath = path.join(reportDir, "report.json");
|
|
1946
|
+
const fixPromptPath = path.join(reportDir, "fix-prompt.md");
|
|
1947
|
+
|
|
1948
|
+
console.log(`Opening ${targetUrl}`);
|
|
1949
|
+
console.log(`Viewport: ${width} × ${height} @ ${scale}x (capture ${width * scale} × ${height * scale}px)`);
|
|
1950
|
+
if (selector) console.log(`Capturing selector: ${selector}`);
|
|
1951
|
+
|
|
1952
|
+
let browser;
|
|
1953
|
+
try {
|
|
1954
|
+
browser = await chromium.launch({ headless: true });
|
|
1955
|
+
} catch (launchError) {
|
|
1956
|
+
throw new Error(`Could not launch Chromium: ${launchError.message}\nInstall the browser once with: npm run install:browsers (or: npx playwright install chromium)`);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
try {
|
|
1960
|
+
const page = await browser.newPage({
|
|
1961
|
+
viewport: { width, height },
|
|
1962
|
+
deviceScaleFactor: scale
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
try {
|
|
1966
|
+
await page.goto(targetUrl, { waitUntil, timeout: 30000 });
|
|
1967
|
+
} catch (gotoError) {
|
|
1968
|
+
// networkidle/long-lived connections (HMR sockets, polling) can stall; fall back and continue.
|
|
1969
|
+
console.log(`Navigation wait "${waitUntil}" did not settle (${gotoError.message.split("\n")[0]}); continuing with current DOM.`);
|
|
1970
|
+
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
|
|
1971
|
+
}
|
|
1972
|
+
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
|
1973
|
+
|
|
1974
|
+
if (selector) {
|
|
1975
|
+
const target = page.locator(selector).first();
|
|
1976
|
+
try {
|
|
1977
|
+
await target.waitFor({ state: "visible", timeout: 15000 });
|
|
1978
|
+
} catch {
|
|
1979
|
+
throw new Error(`Selector not found/visible: ${selector}\nMake sure the implemented root has data-figma-screen and the dev server is serving the page at ${targetUrl}.`);
|
|
1980
|
+
}
|
|
1981
|
+
await target.screenshot({ path: actualPath, animations: "disabled" });
|
|
1982
|
+
} else {
|
|
1983
|
+
await page.screenshot({ path: actualPath, fullPage, animations: "disabled" });
|
|
1984
|
+
}
|
|
1985
|
+
} finally {
|
|
1986
|
+
await browser.close();
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
await copyFile(referencePath, reportReferencePath);
|
|
1990
|
+
|
|
1991
|
+
const reference = await readPng(referencePath);
|
|
1992
|
+
const actual = await readPng(actualPath);
|
|
1993
|
+
const compareWidth = Math.max(reference.width, actual.width);
|
|
1994
|
+
const compareHeight = Math.max(reference.height, actual.height);
|
|
1995
|
+
|
|
1996
|
+
// After the scale fix the two rasters should match; flag if they still differ materially
|
|
1997
|
+
// (e.g. selector element is taller/shorter than the frame), since pixelmatch then pads with white.
|
|
1998
|
+
const dimMismatch = Math.abs(reference.width - actual.width) > 2 || Math.abs(reference.height - actual.height) > 2;
|
|
1999
|
+
if (dimMismatch) {
|
|
2000
|
+
console.log(`\n⚠ Size mismatch: reference ${reference.width}×${reference.height}px vs actual ${actual.width}×${actual.height}px.`);
|
|
2001
|
+
console.log(" The diff will be padded with white in the non-overlapping area and is only partially meaningful.");
|
|
2002
|
+
console.log(" Check the captured element size / selector, or pass --full-page, before trusting the ratio.");
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const normalizedReference = normalizePng(reference, compareWidth, compareHeight);
|
|
2006
|
+
const normalizedActual = normalizePng(actual, compareWidth, compareHeight);
|
|
2007
|
+
|
|
2008
|
+
// --ignore-text: neutralize text regions identically in both images so font rendering
|
|
2009
|
+
// differences (the browser vs Figma's rasterizer never match exactly) don't dominate the diff.
|
|
2010
|
+
// Trade-off: this also hides genuine text bugs, so it's opt-in and focuses the diff on layout/color/spacing.
|
|
2011
|
+
let textMasked = 0;
|
|
2012
|
+
if (ignoreText) {
|
|
2013
|
+
const rects = textMaskRects(spec, scale);
|
|
2014
|
+
maskRegions(normalizedReference, rects);
|
|
2015
|
+
maskRegions(normalizedActual, rects);
|
|
2016
|
+
textMasked = rects.length;
|
|
2017
|
+
console.log(`Masked ${textMasked} text region(s) (--ignore-text).`);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
const diff = new PNG({ width: compareWidth, height: compareHeight });
|
|
2021
|
+
|
|
2022
|
+
const diffPixels = pixelmatch(
|
|
2023
|
+
normalizedReference.data,
|
|
2024
|
+
normalizedActual.data,
|
|
2025
|
+
diff.data,
|
|
2026
|
+
compareWidth,
|
|
2027
|
+
compareHeight,
|
|
2028
|
+
{ threshold }
|
|
2029
|
+
);
|
|
2030
|
+
|
|
2031
|
+
await writePng(diffPath, diff);
|
|
2032
|
+
|
|
2033
|
+
const totalPixels = compareWidth * compareHeight;
|
|
2034
|
+
const diffRatio = diffPixels / totalPixels;
|
|
2035
|
+
const hotspots = detectHotspots(diff, 4, 4, scale);
|
|
2036
|
+
|
|
2037
|
+
const report = {
|
|
2038
|
+
passed: diffRatio <= maxDiffRatio && !dimMismatch,
|
|
2039
|
+
diffRatio,
|
|
2040
|
+
maxDiffRatio,
|
|
2041
|
+
diffPixels,
|
|
2042
|
+
totalPixels,
|
|
2043
|
+
threshold,
|
|
2044
|
+
designScale: scale,
|
|
2045
|
+
dimensionMismatch: dimMismatch,
|
|
2046
|
+
ignoreText,
|
|
2047
|
+
textRegionsMasked: textMasked,
|
|
2048
|
+
reference: {
|
|
2049
|
+
width: reference.width,
|
|
2050
|
+
height: reference.height
|
|
2051
|
+
},
|
|
2052
|
+
actual: {
|
|
2053
|
+
width: actual.width,
|
|
2054
|
+
height: actual.height
|
|
2055
|
+
},
|
|
2056
|
+
compared: {
|
|
2057
|
+
width: compareWidth,
|
|
2058
|
+
height: compareHeight
|
|
2059
|
+
},
|
|
2060
|
+
selector,
|
|
2061
|
+
url: targetUrl,
|
|
2062
|
+
hotspots,
|
|
2063
|
+
files: {
|
|
2064
|
+
reference: reportReferencePath,
|
|
2065
|
+
actual: actualPath,
|
|
2066
|
+
diff: diffPath,
|
|
2067
|
+
report: reportPath,
|
|
2068
|
+
fixPrompt: fixPromptPath
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
|
|
2072
|
+
await writeFile(reportPath, JSON.stringify(report, null, 2), "utf8");
|
|
2073
|
+
await writeFile(fixPromptPath, buildFixPrompt({ report, outputDir }), "utf8");
|
|
2074
|
+
|
|
2075
|
+
console.log("\nVisual comparison complete.");
|
|
2076
|
+
console.log(`Status: ${report.passed ? "PASS" : "FAIL"}`);
|
|
2077
|
+
console.log(`Diff: ${(diffRatio * 100).toFixed(3)}% (${diffPixels}/${totalPixels})`);
|
|
2078
|
+
console.log(`Report: ${reportPath}`);
|
|
2079
|
+
console.log(`Diff: ${diffPath}`);
|
|
2080
|
+
console.log(`Fix: ${fixPromptPath}`);
|
|
2081
|
+
|
|
2082
|
+
if (!report.passed) {
|
|
2083
|
+
process.exitCode = 1;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
|
|
2088
|
+
function slugifyName(value = "screen") {
|
|
2089
|
+
return String(value)
|
|
2090
|
+
.normalize("NFKD")
|
|
2091
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
2092
|
+
.replace(/ı/g, "i")
|
|
2093
|
+
.replace(/İ/g, "i")
|
|
2094
|
+
.replace(/ş/g, "s")
|
|
2095
|
+
.replace(/Ş/g, "s")
|
|
2096
|
+
.replace(/ğ/g, "g")
|
|
2097
|
+
.replace(/Ğ/g, "g")
|
|
2098
|
+
.replace(/ü/g, "u")
|
|
2099
|
+
.replace(/Ü/g, "u")
|
|
2100
|
+
.replace(/ö/g, "o")
|
|
2101
|
+
.replace(/Ö/g, "o")
|
|
2102
|
+
.replace(/ç/g, "c")
|
|
2103
|
+
.replace(/Ç/g, "c")
|
|
2104
|
+
.replace(/[^a-z0-9-_]+/gi, "-")
|
|
2105
|
+
.replace(/^-+|-+$/g, "")
|
|
2106
|
+
.toLowerCase() || "screen";
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
function countDescendants(node) {
|
|
2110
|
+
if (!node?.children?.length) return 0;
|
|
2111
|
+
return node.children.reduce((total, child) => total + 1 + countDescendants(child), 0);
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
function isLikelyScreen(node, options = {}) {
|
|
2115
|
+
const bounds = getNodeBounds(node);
|
|
2116
|
+
if (!bounds) return false;
|
|
2117
|
+
|
|
2118
|
+
const targetWidth = Number(options.screenWidth || 1440);
|
|
2119
|
+
const tolerance = Number(options.widthTolerance || 12);
|
|
2120
|
+
const minHeight = Number(options.minHeight || 500);
|
|
2121
|
+
const maxHeight = Number(options.maxHeight || 4000);
|
|
2122
|
+
|
|
2123
|
+
if (node.type !== "FRAME") return false;
|
|
2124
|
+
if (Math.abs(bounds.width - targetWidth) > tolerance) return false;
|
|
2125
|
+
if (bounds.height < minHeight || bounds.height > maxHeight) return false;
|
|
2126
|
+
|
|
2127
|
+
return true;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
function detectScreens(rootNode, options = {}) {
|
|
2131
|
+
const allNodes = flattenNodes(rootNode);
|
|
2132
|
+
const screenCandidates = [];
|
|
2133
|
+
|
|
2134
|
+
for (const node of allNodes) {
|
|
2135
|
+
if (!isLikelyScreen(node, options)) continue;
|
|
2136
|
+
const bounds = getNodeBounds(node);
|
|
2137
|
+
const descendantCount = countDescendants(node);
|
|
2138
|
+
|
|
2139
|
+
// Skip empty wrappers unless root itself is the only candidate.
|
|
2140
|
+
if (descendantCount < 3 && node.id !== rootNode.id) continue;
|
|
2141
|
+
|
|
2142
|
+
screenCandidates.push({
|
|
2143
|
+
id: node.id,
|
|
2144
|
+
name: node.name,
|
|
2145
|
+
type: node.type,
|
|
2146
|
+
width: Math.round(bounds.width),
|
|
2147
|
+
height: Math.round(bounds.height),
|
|
2148
|
+
x: Math.round(bounds.x),
|
|
2149
|
+
y: Math.round(bounds.y),
|
|
2150
|
+
descendants: descendantCount
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// If the selected node itself is a screen and there are no nested screens, keep it as single screen.
|
|
2155
|
+
// If it is a board containing many screens, remove the board/root from the list when there are nested screens.
|
|
2156
|
+
const nested = screenCandidates.filter((candidate) => candidate.id !== rootNode.id);
|
|
2157
|
+
const chosen = nested.length ? nested : screenCandidates;
|
|
2158
|
+
|
|
2159
|
+
return chosen
|
|
2160
|
+
.sort((a, b) => a.y - b.y || a.x - b.x)
|
|
2161
|
+
.map((screen, index) => ({
|
|
2162
|
+
...screen,
|
|
2163
|
+
index: index + 1,
|
|
2164
|
+
slug: `${String(index + 1).padStart(2, "0")}-${slugifyName(screen.name)}-${screen.id.replaceAll(":", "-")}`
|
|
2165
|
+
}));
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
function pascalCase(name = "") {
|
|
2169
|
+
return String(name)
|
|
2170
|
+
.split("/").map((s) => s.trim()).filter(Boolean).join(" ") // flatten "Group/Button" paths
|
|
2171
|
+
.replace(/=.*$/, "") // drop variant value "State=Hover" -> "State"
|
|
2172
|
+
.normalize("NFKD").replace(/[̀-ͯ]/g, "")
|
|
2173
|
+
.replace(/[^a-z0-9]+/gi, " ")
|
|
2174
|
+
.trim()
|
|
2175
|
+
.split(/\s+/)
|
|
2176
|
+
.filter(Boolean)
|
|
2177
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
2178
|
+
.join("") || "Component";
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
function collectInstances(rootNode) {
|
|
2182
|
+
return flattenNodes(rootNode).filter((node) => node.type === "INSTANCE" && node.componentId);
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
// Builds a map of nodeId -> screen for every node inside each detected screen subtree,
|
|
2186
|
+
// so we can tell which screens a given component instance appears in.
|
|
2187
|
+
function buildScreenMembership(rootNode, screens) {
|
|
2188
|
+
const idToNode = new Map(flattenNodes(rootNode).map((node) => [node.id, node]));
|
|
2189
|
+
const membership = new Map();
|
|
2190
|
+
for (const screen of screens) {
|
|
2191
|
+
const screenNode = idToNode.get(screen.id);
|
|
2192
|
+
if (!screenNode) continue;
|
|
2193
|
+
for (const node of flattenNodes(screenNode)) {
|
|
2194
|
+
if (!membership.has(node.id)) membership.set(node.id, screen);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return membership;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
function camelProp(name) {
|
|
2201
|
+
const parts = String(name).replace(/[^a-z0-9]+/gi, " ").trim().split(/\s+/).filter(Boolean);
|
|
2202
|
+
if (!parts.length) return "prop";
|
|
2203
|
+
return parts.map((w, i) => (i === 0 ? w.toLowerCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())).join("");
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
// Turn variant names ("Type=Primary, Size=sm") + non-variant component properties into a structured
|
|
2207
|
+
// prop API: enum axes, booleans, text and instance-swap slots.
|
|
2208
|
+
function buildPropSchema(variants, propTypes = new Map()) {
|
|
2209
|
+
const axes = {};
|
|
2210
|
+
for (const v of variants) {
|
|
2211
|
+
for (const part of String(v.name || "").split(",")) {
|
|
2212
|
+
const eq = part.indexOf("=");
|
|
2213
|
+
if (eq === -1) continue;
|
|
2214
|
+
const key = part.slice(0, eq).trim();
|
|
2215
|
+
const val = part.slice(eq + 1).trim();
|
|
2216
|
+
if (key && val) (axes[key] ||= new Set()).add(val);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
const props = [];
|
|
2220
|
+
const seen = new Set();
|
|
2221
|
+
for (const [key, set] of Object.entries(axes)) {
|
|
2222
|
+
const vals = [...set];
|
|
2223
|
+
const isBool = vals.length > 0 && vals.every((x) => /^(true|false|yes|no|on|off)$/i.test(x));
|
|
2224
|
+
const cp = camelProp(key);
|
|
2225
|
+
seen.add(cp);
|
|
2226
|
+
props.push(isBool ? { name: cp, figmaName: key, type: "boolean" } : { name: cp, figmaName: key, type: "enum", values: vals });
|
|
2227
|
+
}
|
|
2228
|
+
for (const [name, t] of propTypes) {
|
|
2229
|
+
if (t === "VARIANT") continue; // covered by axes above
|
|
2230
|
+
const cp = camelProp(name);
|
|
2231
|
+
if (seen.has(cp)) continue;
|
|
2232
|
+
seen.add(cp);
|
|
2233
|
+
props.push({ name: cp, figmaName: name, type: t === "BOOLEAN" ? "boolean" : t === "INSTANCE_SWAP" ? "slot" : "string" });
|
|
2234
|
+
}
|
|
2235
|
+
return props;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
function propsToTs(componentName, props) {
|
|
2239
|
+
if (!props.length) return "";
|
|
2240
|
+
const body = props.map((p) => {
|
|
2241
|
+
const t = p.type === "enum" ? p.values.map((v) => JSON.stringify(v)).join(" | ")
|
|
2242
|
+
: p.type === "boolean" ? "boolean"
|
|
2243
|
+
: p.type === "slot" ? "React.ReactNode"
|
|
2244
|
+
: "string";
|
|
2245
|
+
return ` ${p.name}: ${t};`;
|
|
2246
|
+
}).join("\n");
|
|
2247
|
+
return `type ${componentName}Props = {\n${body}\n}`;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
// Structural fingerprint of a container: type + rounded size + child count + sorted child types.
|
|
2251
|
+
// Two nodes with the same signature are very likely copy-pasted duplicates of the same UI block.
|
|
2252
|
+
function nodeSignature(node) {
|
|
2253
|
+
const b = getNodeBounds(node);
|
|
2254
|
+
const w = b ? Math.round(b.width) : 0;
|
|
2255
|
+
const h = b ? Math.round(b.height) : 0;
|
|
2256
|
+
const childTypes = Array.isArray(node.children) ? node.children.map((c) => c.type).sort().join(",") : "";
|
|
2257
|
+
const childCount = node.children?.length || 0;
|
|
2258
|
+
return `${node.type}|${w}x${h}|n${childCount}|[${childTypes}]`;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// Heuristic: find repeated identical container subtrees that are NOT Figma components/instances
|
|
2262
|
+
// (i.e. copy-pasted groups). These are componentization candidates the instance-based library can't see.
|
|
2263
|
+
function detectRepeatedPatterns(rootNode, { minCount = 3, minChildren = 2 } = {}) {
|
|
2264
|
+
const groups = new Map();
|
|
2265
|
+
for (const node of flattenNodes(rootNode)) {
|
|
2266
|
+
if (node.id === rootNode.id) continue;
|
|
2267
|
+
if (!["FRAME", "GROUP"].includes(node.type)) continue; // skip INSTANCE/COMPONENT (already detected) and leaves
|
|
2268
|
+
const childCount = node.children?.length || 0;
|
|
2269
|
+
if (childCount < minChildren) continue;
|
|
2270
|
+
const b = getNodeBounds(node);
|
|
2271
|
+
if (!b || b.width < 8 || b.height < 8) continue;
|
|
2272
|
+
const sig = nodeSignature(node);
|
|
2273
|
+
if (!groups.has(sig)) groups.set(sig, { count: 0, sample: node, names: new Set() });
|
|
2274
|
+
const g = groups.get(sig);
|
|
2275
|
+
g.count++;
|
|
2276
|
+
g.names.add(node.name);
|
|
2277
|
+
}
|
|
2278
|
+
return [...groups.values()]
|
|
2279
|
+
.filter((g) => g.count >= minCount)
|
|
2280
|
+
.map((g) => {
|
|
2281
|
+
const b = getNodeBounds(g.sample);
|
|
2282
|
+
return {
|
|
2283
|
+
count: g.count,
|
|
2284
|
+
sampleName: g.sample.name,
|
|
2285
|
+
suggestedComponentName: pascalCase([...g.names][0] || g.sample.name),
|
|
2286
|
+
type: g.sample.type,
|
|
2287
|
+
childCount: g.sample.children?.length || 0,
|
|
2288
|
+
size: b ? { width: Math.round(b.width), height: Math.round(b.height) } : null,
|
|
2289
|
+
names: [...g.names].slice(0, 5)
|
|
2290
|
+
};
|
|
2291
|
+
})
|
|
2292
|
+
.sort((a, b) => b.count - a.count)
|
|
2293
|
+
.slice(0, 15);
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// Groups component instances across all screens by their underlying component (or variant set),
|
|
2297
|
+
// so the implementer can build each shared component once and reuse it everywhere.
|
|
2298
|
+
function extractComponentLibrary({ rootNode, componentsMeta = {}, componentSetsMeta = {}, screens = [] }) {
|
|
2299
|
+
const instances = collectInstances(rootNode);
|
|
2300
|
+
const membership = screens.length ? buildScreenMembership(rootNode, screens) : new Map();
|
|
2301
|
+
|
|
2302
|
+
const families = new Map();
|
|
2303
|
+
const componentNameById = {};
|
|
2304
|
+
|
|
2305
|
+
for (const inst of instances) {
|
|
2306
|
+
const meta = componentsMeta[inst.componentId] || {};
|
|
2307
|
+
const setId = meta.componentSetId || null;
|
|
2308
|
+
const setMeta = setId ? componentSetsMeta[setId] : null;
|
|
2309
|
+
const displayName = setMeta?.name || meta.name || inst.name;
|
|
2310
|
+
componentNameById[inst.componentId] = displayName;
|
|
2311
|
+
|
|
2312
|
+
// Variants of one COMPONENT_SET collapse into a single family with a `variant` prop.
|
|
2313
|
+
const familyKey = setId || inst.componentId;
|
|
2314
|
+
if (!families.has(familyKey)) {
|
|
2315
|
+
families.set(familyKey, {
|
|
2316
|
+
name: displayName,
|
|
2317
|
+
suggestedComponentName: pascalCase(displayName),
|
|
2318
|
+
isVariantSet: Boolean(setId),
|
|
2319
|
+
description: setMeta?.description || meta.description || "",
|
|
2320
|
+
usageCount: 0,
|
|
2321
|
+
screens: new Set(),
|
|
2322
|
+
variants: new Map(),
|
|
2323
|
+
propTypes: new Map(),
|
|
2324
|
+
sampleBounds: null
|
|
2325
|
+
});
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const family = families.get(familyKey);
|
|
2329
|
+
family.usageCount++;
|
|
2330
|
+
const variantName = meta.name || inst.name;
|
|
2331
|
+
const variant = family.variants.get(inst.componentId) || { name: variantName, count: 0 };
|
|
2332
|
+
variant.count++;
|
|
2333
|
+
family.variants.set(inst.componentId, variant);
|
|
2334
|
+
|
|
2335
|
+
const screen = membership.get(inst.id);
|
|
2336
|
+
if (screen) family.screens.add(screen.name);
|
|
2337
|
+
|
|
2338
|
+
if (!family.sampleBounds) {
|
|
2339
|
+
const b = getNodeBounds(inst);
|
|
2340
|
+
if (b) family.sampleBounds = { width: Math.round(b.width), height: Math.round(b.height) };
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
for (const [propKey, propVal] of Object.entries(inst.componentProperties || {})) {
|
|
2344
|
+
const name = propKey.replace(/#\d+.*$/, ""); // strip Figma's internal "#123:456" suffix
|
|
2345
|
+
family.propTypes.set(name, propVal?.type || "TEXT");
|
|
2346
|
+
}
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
const list = [...families.values()]
|
|
2350
|
+
.map((family) => {
|
|
2351
|
+
const variants = [...family.variants.values()].sort((a, b) => b.count - a.count);
|
|
2352
|
+
const propSchema = buildPropSchema(variants, family.propTypes);
|
|
2353
|
+
return {
|
|
2354
|
+
name: family.name,
|
|
2355
|
+
suggestedComponentName: family.suggestedComponentName,
|
|
2356
|
+
isVariantSet: family.isVariantSet,
|
|
2357
|
+
description: family.description,
|
|
2358
|
+
usageCount: family.usageCount,
|
|
2359
|
+
screenCount: family.screens.size,
|
|
2360
|
+
screens: [...family.screens],
|
|
2361
|
+
variants,
|
|
2362
|
+
props: propSchema.map((p) => p.name),
|
|
2363
|
+
propSchema,
|
|
2364
|
+
propsType: propsToTs(family.suggestedComponentName, propSchema),
|
|
2365
|
+
sampleBounds: family.sampleBounds
|
|
2366
|
+
};
|
|
2367
|
+
})
|
|
2368
|
+
.sort((a, b) => b.usageCount - a.usageCount || b.screenCount - a.screenCount);
|
|
2369
|
+
|
|
2370
|
+
const repeatedPatterns = detectRepeatedPatterns(rootNode);
|
|
2371
|
+
|
|
2372
|
+
return { list, componentNameById, totalInstances: instances.length, repeatedPatterns };
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
function buildComponentLibraryMarkdown(library, { title = "Shared Component Library", scope = "" } = {}) {
|
|
2376
|
+
const lines = [];
|
|
2377
|
+
const repeated = library.repeatedPatterns || [];
|
|
2378
|
+
lines.push(`# ${title}`, "");
|
|
2379
|
+
if (scope) lines.push(scope, "");
|
|
2380
|
+
|
|
2381
|
+
if (library.list.length) {
|
|
2382
|
+
const reusedAcrossScreens = library.list.filter((c) => c.screenCount >= 2);
|
|
2383
|
+
const reusedMultiple = library.list.filter((c) => c.usageCount >= 2);
|
|
2384
|
+
|
|
2385
|
+
lines.push(`Total instances: **${library.totalInstances}** across **${library.list.length}** distinct components.`);
|
|
2386
|
+
lines.push(`Reused on 2+ screens: **${reusedAcrossScreens.length}** · Used 2+ times: **${reusedMultiple.length}**`, "");
|
|
2387
|
+
lines.push("## Reuse rule", "");
|
|
2388
|
+
lines.push("Build each component below **once** as a reusable component in the project, then import it in every screen.");
|
|
2389
|
+
lines.push("Do not re-implement these per screen. Variant sets become a single component with a `variant` prop.", "");
|
|
2390
|
+
|
|
2391
|
+
lines.push("## Components (by usage)", "");
|
|
2392
|
+
lines.push("| Suggested name | Figma name | Uses | Screens | Variants | Props | Sample size |");
|
|
2393
|
+
lines.push("|---|---|---:|---:|---|---|---|");
|
|
2394
|
+
for (const c of library.list) {
|
|
2395
|
+
const variants = c.isVariantSet ? c.variants.map((v) => `${v.name}×${v.count}`).join(", ") : "—";
|
|
2396
|
+
const props = c.props.length ? c.props.join(", ") : "—";
|
|
2397
|
+
const size = c.sampleBounds ? `${c.sampleBounds.width}×${c.sampleBounds.height}` : "—";
|
|
2398
|
+
lines.push(`| \`${c.suggestedComponentName}\` | ${c.name} | ${c.usageCount} | ${c.screenCount || "—"} | ${variants} | ${props} | ${size} |`);
|
|
2399
|
+
}
|
|
2400
|
+
lines.push("");
|
|
2401
|
+
|
|
2402
|
+
lines.push("## Suggested build order", "");
|
|
2403
|
+
lines.push("Highest reuse first — these save the most duplicate code:", "");
|
|
2404
|
+
for (const c of library.list.slice(0, 20)) {
|
|
2405
|
+
const where = c.screens.length ? ` (screens: ${c.screens.join(", ")})` : "";
|
|
2406
|
+
lines.push(`- \`${c.suggestedComponentName}\` — used ${c.usageCount}×${where}`);
|
|
2407
|
+
}
|
|
2408
|
+
lines.push("");
|
|
2409
|
+
|
|
2410
|
+
// Variant Mapping → a ready prop API per component, so the AI builds the right interface once.
|
|
2411
|
+
const withProps = library.list.filter((c) => c.propSchema && c.propSchema.length);
|
|
2412
|
+
if (withProps.length) {
|
|
2413
|
+
lines.push("## Component prop APIs (from Figma variants & properties)", "");
|
|
2414
|
+
lines.push("Suggested prop interface for each component. Enums come from variant axes; booleans/text/slots from component properties.", "");
|
|
2415
|
+
for (const c of withProps.slice(0, 30)) {
|
|
2416
|
+
lines.push("```ts");
|
|
2417
|
+
lines.push(c.propsType);
|
|
2418
|
+
lines.push("```");
|
|
2419
|
+
}
|
|
2420
|
+
lines.push("");
|
|
2421
|
+
}
|
|
2422
|
+
} else {
|
|
2423
|
+
lines.push("No Figma component instances detected in this selection.", "");
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
// Heuristic section: copy-pasted (non-component) blocks that repeat — strong componentization candidates.
|
|
2427
|
+
if (repeated.length) {
|
|
2428
|
+
lines.push("## Repeated patterns (heuristic — not Figma components)", "");
|
|
2429
|
+
lines.push("These identical-looking blocks are **not** Figma components but repeat in the design (likely copy-pasted).");
|
|
2430
|
+
lines.push("Strongly consider building each as a reusable component instead of duplicating markup:", "");
|
|
2431
|
+
lines.push("| Suggested name | Repeats | Type | Children | Size |");
|
|
2432
|
+
lines.push("|---|---:|---|---:|---|");
|
|
2433
|
+
for (const p of repeated) {
|
|
2434
|
+
const size = p.size ? `${p.size.width}×${p.size.height}` : "—";
|
|
2435
|
+
lines.push(`| \`${p.suggestedComponentName}\` | ${p.count} | ${p.type} | ${p.childCount} | ${size} |`);
|
|
2436
|
+
}
|
|
2437
|
+
lines.push("");
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
return lines.join("\n");
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
function detectComponents(rootNode, screens = []) {
|
|
2444
|
+
const screenIds = new Set(screens.map((screen) => screen.id));
|
|
2445
|
+
const allNodes = flattenNodes(rootNode);
|
|
2446
|
+
const components = [];
|
|
2447
|
+
|
|
2448
|
+
for (const node of allNodes) {
|
|
2449
|
+
if (screenIds.has(node.id)) continue;
|
|
2450
|
+
const bounds = getNodeBounds(node);
|
|
2451
|
+
if (!bounds) continue;
|
|
2452
|
+
|
|
2453
|
+
if (["COMPONENT", "COMPONENT_SET", "INSTANCE"].includes(node.type)) {
|
|
2454
|
+
components.push({
|
|
2455
|
+
id: node.id,
|
|
2456
|
+
name: node.name,
|
|
2457
|
+
type: node.type,
|
|
2458
|
+
width: Math.round(bounds.width),
|
|
2459
|
+
height: Math.round(bounds.height),
|
|
2460
|
+
x: Math.round(bounds.x),
|
|
2461
|
+
y: Math.round(bounds.y)
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2466
|
+
return components.sort((a, b) => a.y - b.y || a.x - b.x);
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
function buildBoardMarkdown({ rootNode, source, screens, components }) {
|
|
2470
|
+
const rootBounds = getNodeBounds(rootNode);
|
|
2471
|
+
const lines = [];
|
|
2472
|
+
lines.push("# Figma Board Analysis", "");
|
|
2473
|
+
lines.push(`Board: **${rootNode.name}**`);
|
|
2474
|
+
lines.push(`Node ID: \`${rootNode.id}\``);
|
|
2475
|
+
if (rootBounds) lines.push(`Size: **${Math.round(rootBounds.width)} × ${Math.round(rootBounds.height)}**`);
|
|
2476
|
+
lines.push(`Source: ${source.figmaUrl}`, "");
|
|
2477
|
+
lines.push("## Detected screens", "");
|
|
2478
|
+
if (!screens.length) {
|
|
2479
|
+
lines.push("No screens detected at the configured width (see `board-analysis.json` → detection; try `--mobile` or `--screen-width`).");
|
|
2480
|
+
} else {
|
|
2481
|
+
lines.push("| # | Name | Node ID | Size | Descendants | Output folder |");
|
|
2482
|
+
lines.push("|---:|---|---|---:|---:|---|");
|
|
2483
|
+
for (const screen of screens) {
|
|
2484
|
+
lines.push(`| ${screen.index} | ${screen.name} | \`${screen.id}\` | ${screen.width}×${screen.height} | ${screen.descendants} | \`${screen.slug}\` |`);
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
lines.push("", "## Detected components / instances", "");
|
|
2488
|
+
const maxComponents = 120;
|
|
2489
|
+
if (!components.length) {
|
|
2490
|
+
lines.push("No component-like nodes detected.");
|
|
2491
|
+
} else {
|
|
2492
|
+
lines.push(`Showing first ${Math.min(maxComponents, components.length)} of ${components.length}.`);
|
|
2493
|
+
lines.push("");
|
|
2494
|
+
lines.push("| # | Name | Type | Node ID | Size |");
|
|
2495
|
+
lines.push("|---:|---|---|---|---:|");
|
|
2496
|
+
for (const [index, component] of components.slice(0, maxComponents).entries()) {
|
|
2497
|
+
lines.push(`| ${index + 1} | ${component.name} | ${component.type} | \`${component.id}\` | ${component.width}×${component.height} |`);
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
lines.push("");
|
|
2501
|
+
lines.push("## Implementation strategy", "");
|
|
2502
|
+
lines.push("Do not implement the full board as one screen.");
|
|
2503
|
+
lines.push("Implement screens one by one using each generated `prompt-for-claude.md`.");
|
|
2504
|
+
lines.push("Use shared components/instances as a design-system reference, not as standalone pages.");
|
|
2505
|
+
return lines.join("\n");
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
async function exportScreenFromNode({
|
|
2509
|
+
figmaUrl, source, rootNode, nodeResponse, outDir, outDirName,
|
|
2510
|
+
isImplementMode = false, componentNameById = {}, imageFillUrls = null, componentLibrary = null,
|
|
2511
|
+
scale = REFERENCE_SCALE, referenceImageUrl = null, iconCache = new Map()
|
|
2512
|
+
}) {
|
|
2513
|
+
await mkdir(outDir, { recursive: true });
|
|
2514
|
+
await writeFile(path.join(outDir, "figma-node.json"), JSON.stringify(nodeResponse, null, 2), "utf8");
|
|
2515
|
+
|
|
2516
|
+
// Board exports pre-render all screens in one batch call and pass referenceImageUrl in.
|
|
2517
|
+
// Single-node exports render here.
|
|
2518
|
+
let renderedUrl = referenceImageUrl;
|
|
2519
|
+
if (!renderedUrl) {
|
|
2520
|
+
const imageUrl = new URL(`https://api.figma.com/v1/images/${source.fileKey}`);
|
|
2521
|
+
imageUrl.searchParams.set("ids", source.nodeId);
|
|
2522
|
+
imageUrl.searchParams.set("format", "png");
|
|
2523
|
+
imageUrl.searchParams.set("scale", String(scale));
|
|
2524
|
+
imageUrl.searchParams.set("use_absolute_bounds", "true");
|
|
2525
|
+
|
|
2526
|
+
console.log(`Exporting reference PNG: ${rootNode.name}`);
|
|
2527
|
+
const imageResponse = await figmaFetch(imageUrl.toString(), source.token);
|
|
2528
|
+
if (imageResponse.err) {
|
|
2529
|
+
throw new Error(`Figma image render failed: ${imageResponse.err}`);
|
|
2530
|
+
}
|
|
2531
|
+
renderedUrl = imageResponse.images?.[source.nodeId];
|
|
2532
|
+
if (!renderedUrl) {
|
|
2533
|
+
throw new Error(`Could not render node image (no URL returned). Response: ${JSON.stringify(imageResponse, null, 2)}`);
|
|
2534
|
+
}
|
|
2535
|
+
} else {
|
|
2536
|
+
console.log(`Exporting reference PNG: ${rootNode.name}`);
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
await downloadFile(renderedUrl, path.join(outDir, "reference.png"));
|
|
2540
|
+
|
|
2541
|
+
const allNodes = flattenNodes(rootNode);
|
|
2542
|
+
const boundedNodes = allNodes.filter((node) => getNodeBounds(node));
|
|
2543
|
+
|
|
2544
|
+
// Download image fills used in this screen so the debug renderer can show real images.
|
|
2545
|
+
const imageRefMap = {};
|
|
2546
|
+
const usedRefs = [...new Set(boundedNodes.map((node) => firstImageFill(node)?.imageRef).filter(Boolean))];
|
|
2547
|
+
if (usedRefs.length && imageFillUrls) {
|
|
2548
|
+
const assetsDir = path.join(outDir, "assets");
|
|
2549
|
+
await mkdir(assetsDir, { recursive: true });
|
|
2550
|
+
for (const ref of usedRefs) {
|
|
2551
|
+
const remote = imageFillUrls[ref];
|
|
2552
|
+
if (!remote) continue;
|
|
2553
|
+
try {
|
|
2554
|
+
const localName = `${ref.replace(/[^a-z0-9]/gi, "_")}.png`;
|
|
2555
|
+
await downloadFile(remote, path.join(assetsDir, localName));
|
|
2556
|
+
imageRefMap[ref] = `./assets/${localName}`;
|
|
2557
|
+
} catch {
|
|
2558
|
+
// best-effort: a missing image fill should not fail the export
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// Classify assets (library icons / custom icons / images) for assets.md + spec.
|
|
2564
|
+
// NO SVG downloads: library icons are rendered live from the icon library's CDN in the
|
|
2565
|
+
// debug preview; custom/unknown vectors stay as boxes (their own fill color) for you to supply.
|
|
2566
|
+
let assetManifest = { libraryIcons: [], customIcons: [], images: [] };
|
|
2567
|
+
const nodeIconMap = {}; // nodeId -> { set, name } for library icon roots (e.g. lucide chevron-right)
|
|
2568
|
+
try {
|
|
2569
|
+
assetManifest = collectScreenAssets(rootNode, boundedNodes, componentNameById, imageRefMap);
|
|
2570
|
+
for (const node of boundedNodes) {
|
|
2571
|
+
if (!isIconRoot(node, componentNameById)) continue;
|
|
2572
|
+
const name = (node.componentId && componentNameById[node.componentId]) || node.name || "";
|
|
2573
|
+
const lib = detectIconLibrary(name);
|
|
2574
|
+
if (lib) nodeIconMap[node.id] = { set: lib.set, name: lib.iconName };
|
|
2575
|
+
}
|
|
2576
|
+
if (assetManifest.libraryIcons.length || assetManifest.customIcons.length || assetManifest.images.length) {
|
|
2577
|
+
console.log(`Assets: ${assetManifest.libraryIcons.length} library icon(s) (rendered via CDN), ${assetManifest.customIcons.length} custom icon box(es), ${assetManifest.images.length} image(s).`);
|
|
2578
|
+
}
|
|
2579
|
+
} catch (e) {
|
|
2580
|
+
console.log(`Note: asset/icon resolution skipped (${(e.message || String(e)).split("\n")[0]}).`);
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
const spec = buildSpecJson(rootNode, boundedNodes, {
|
|
2584
|
+
figmaUrl,
|
|
2585
|
+
fileKey: source.fileKey,
|
|
2586
|
+
nodeId: source.nodeId
|
|
2587
|
+
}, componentNameById, scale);
|
|
2588
|
+
spec.assets = assetManifest;
|
|
2589
|
+
|
|
2590
|
+
// Hover-state (hidden) icons: shown dimmed in the debug preview + listed in inventory.md.
|
|
2591
|
+
let hoverIcons = [];
|
|
2592
|
+
try {
|
|
2593
|
+
hoverIcons = collectHiddenIconRoots(rootNode, componentNameById);
|
|
2594
|
+
} catch {
|
|
2595
|
+
// best-effort
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
await writeFile(path.join(outDir, "spec.json"), JSON.stringify(spec, null, 2), "utf8");
|
|
2599
|
+
await writeFile(path.join(outDir, "assets.md"), buildAssetsMarkdown(assetManifest, rootNode), "utf8");
|
|
2600
|
+
await writeFile(path.join(outDir, "inventory.md"), buildInventoryMarkdown(rootNode, boundedNodes, componentNameById, hoverIcons), "utf8");
|
|
2601
|
+
|
|
2602
|
+
const { html, css } = buildHtml(rootNode, boundedNodes, imageRefMap, nodeIconMap, hoverIcons);
|
|
2603
|
+
await writeFile(path.join(outDir, "index.html"), buildReferencePreviewHtml(rootNode), "utf8");
|
|
2604
|
+
await writeFile(path.join(outDir, "debug-render.html"), html, "utf8");
|
|
2605
|
+
await writeFile(path.join(outDir, "styles.css"), css, "utf8");
|
|
2606
|
+
await writeFile(path.join(outDir, "inspect.md"), buildInspectMarkdown(rootNode, boundedNodes, {
|
|
2607
|
+
figmaUrl,
|
|
2608
|
+
fileKey: source.fileKey,
|
|
2609
|
+
nodeId: source.nodeId
|
|
2610
|
+
}), "utf8");
|
|
2611
|
+
await writeFile(path.join(outDir, "prompt-for-claude.md"), buildClaudePrompt(rootNode, assetManifest), "utf8");
|
|
2612
|
+
await writeFile(path.join(outDir, "visual-test.spec.ts"), buildVisualTest(rootNode, outDirName, scale), "utf8");
|
|
2613
|
+
|
|
2614
|
+
// For single-node exports, write a screen-scoped component library so reuse is visible even without a board.
|
|
2615
|
+
if (componentLibrary && (componentLibrary.list.length || componentLibrary.repeatedPatterns?.length)) {
|
|
2616
|
+
await writeFile(path.join(outDir, "component-library.md"),
|
|
2617
|
+
buildComponentLibraryMarkdown(componentLibrary, { scope: `Components used in **${rootNode.name}**.` }), "utf8");
|
|
2618
|
+
await writeFile(path.join(outDir, "components.json"), JSON.stringify(componentLibrary, null, 2), "utf8");
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
if (isImplementMode) {
|
|
2622
|
+
console.log("");
|
|
2623
|
+
console.log(`Implementation handoff: ${rootNode.name}`);
|
|
2624
|
+
console.log("----------------------");
|
|
2625
|
+
console.log(`Prompt: ${path.join(outDir, "prompt-for-claude.md")}`);
|
|
2626
|
+
console.log(`Selector: data-figma-screen="${rootNode.id}"`);
|
|
2627
|
+
console.log(`Compare: figma-pixel-kit compare "${outDirName}" --url "http://localhost:3000" --selector "[data-figma-screen='${rootNode.id}']"`);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
return { outDir, outDirName, rootNode };
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
async function exportSingleNodeCommand(figmaUrl, options = {}) {
|
|
2634
|
+
const isImplementMode = options.mode === "implement";
|
|
2635
|
+
await initCommand({ silent: true });
|
|
2636
|
+
|
|
2637
|
+
const token = await resolveFigmaToken();
|
|
2638
|
+
if (!token) {
|
|
2639
|
+
throw new Error('Missing Figma token. Run once: figma-pixel-kit token "figd_xxxxxxxxx"');
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
const { source, nodeResponse, rootNode, componentsMeta, componentSetsMeta } = await getFigmaRootNode(figmaUrl, token);
|
|
2643
|
+
const safeNodeId = source.nodeId.replaceAll(":", "-");
|
|
2644
|
+
const outDir = path.join(ROOT, "design-ai", `${source.fileName}-${safeNodeId}`);
|
|
2645
|
+
const outDirName = path.basename(outDir);
|
|
2646
|
+
|
|
2647
|
+
const componentLibrary = extractComponentLibrary({ rootNode, componentsMeta, componentSetsMeta, screens: [] });
|
|
2648
|
+
const imageFillUrls = await fetchImageFillUrls(source.fileKey, token);
|
|
2649
|
+
const scale = resolveExportScale(options.scale);
|
|
2650
|
+
|
|
2651
|
+
const result = await exportScreenFromNode({
|
|
2652
|
+
figmaUrl,
|
|
2653
|
+
source: { ...source, token },
|
|
2654
|
+
rootNode,
|
|
2655
|
+
nodeResponse,
|
|
2656
|
+
outDir,
|
|
2657
|
+
outDirName,
|
|
2658
|
+
isImplementMode,
|
|
2659
|
+
componentNameById: componentLibrary.componentNameById,
|
|
2660
|
+
componentLibrary,
|
|
2661
|
+
imageFillUrls,
|
|
2662
|
+
scale
|
|
2663
|
+
});
|
|
2664
|
+
|
|
2665
|
+
if (componentLibrary.list.length) {
|
|
2666
|
+
console.log(`Components: ${componentLibrary.list.length} reusable component(s) detected → ${path.join(outDir, "component-library.md")}`);
|
|
2667
|
+
}
|
|
2668
|
+
console.log("\nDone.");
|
|
2669
|
+
console.log(`Output: ${outDir}`);
|
|
2670
|
+
console.log(`Open: ${path.join(outDir, "index.html")}`);
|
|
2671
|
+
console.log(`Debug: ${path.join(outDir, "debug-render.html")}`);
|
|
2672
|
+
console.log(`Inspect:${path.join(outDir, "inspect.md")}`);
|
|
2673
|
+
console.log(`Prompt: ${path.join(outDir, "prompt-for-claude.md")}`);
|
|
2674
|
+
console.log(`Visual: figma-pixel-kit compare "${outDirName}" --url "http://localhost:3000" --selector "[data-figma-screen='${rootNode.id}']"`);
|
|
2675
|
+
|
|
2676
|
+
return result;
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
async function analyzeBoardCommand(figmaUrl, options = {}) {
|
|
2680
|
+
await initCommand({ silent: true });
|
|
2681
|
+
|
|
2682
|
+
const token = await resolveFigmaToken();
|
|
2683
|
+
if (!token) {
|
|
2684
|
+
throw new Error('Missing Figma token. Run once: figma-pixel-kit token "figd_xxxxxxxxx"');
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
const { source, nodeResponse, rootNode, componentsMeta, componentSetsMeta } = await getFigmaRootNode(figmaUrl, token);
|
|
2688
|
+
const screens = detectScreens(rootNode, options);
|
|
2689
|
+
const components = detectComponents(rootNode, screens);
|
|
2690
|
+
const componentLibrary = extractComponentLibrary({ rootNode, componentsMeta, componentSetsMeta, screens });
|
|
2691
|
+
|
|
2692
|
+
const safeNodeId = source.nodeId.replaceAll(":", "-");
|
|
2693
|
+
const boardDirName = `${source.fileName}-${safeNodeId}-board`;
|
|
2694
|
+
const boardDir = path.join(ROOT, "design-ai", boardDirName);
|
|
2695
|
+
await mkdir(boardDir, { recursive: true });
|
|
2696
|
+
|
|
2697
|
+
const analysis = {
|
|
2698
|
+
source: {
|
|
2699
|
+
figmaUrl,
|
|
2700
|
+
fileKey: source.fileKey,
|
|
2701
|
+
nodeId: source.nodeId,
|
|
2702
|
+
name: rootNode.name
|
|
2703
|
+
},
|
|
2704
|
+
detection: {
|
|
2705
|
+
screenWidth: Number(options.screenWidth || 1440),
|
|
2706
|
+
widthTolerance: Number(options.widthTolerance || 12),
|
|
2707
|
+
minHeight: Number(options.minHeight || 500),
|
|
2708
|
+
maxHeight: Number(options.maxHeight || 4000)
|
|
2709
|
+
},
|
|
2710
|
+
screens,
|
|
2711
|
+
components
|
|
2712
|
+
};
|
|
2713
|
+
|
|
2714
|
+
await writeFile(path.join(boardDir, "board-analysis.json"), JSON.stringify(analysis, null, 2), "utf8");
|
|
2715
|
+
await writeFile(path.join(boardDir, "screens.md"), buildBoardMarkdown({ rootNode, source: { ...source, figmaUrl }, screens, components }), "utf8");
|
|
2716
|
+
await writeFile(path.join(boardDir, "figma-board-node.json"), JSON.stringify(nodeResponse, null, 2), "utf8");
|
|
2717
|
+
await writeFile(path.join(boardDir, "component-library.md"),
|
|
2718
|
+
buildComponentLibraryMarkdown(componentLibrary, { scope: `Shared components across **${screens.length}** screen(s) in **${rootNode.name}**.` }), "utf8");
|
|
2719
|
+
await writeFile(path.join(boardDir, "components.json"), JSON.stringify(componentLibrary, null, 2), "utf8");
|
|
2720
|
+
|
|
2721
|
+
console.log("");
|
|
2722
|
+
console.log(`Detected ${screens.length} screen(s), ${components.length} component-like node(s), ${componentLibrary.list.length} reusable component(s).`);
|
|
2723
|
+
console.log(`Board output: ${boardDir}`);
|
|
2724
|
+
console.log(`Screens: ${path.join(boardDir, "screens.md")}`);
|
|
2725
|
+
console.log(`Components: ${path.join(boardDir, "component-library.md")}`);
|
|
2726
|
+
|
|
2727
|
+
return { token, source, nodeResponse, rootNode, screens, components, boardDir, boardDirName, componentLibrary, componentsMeta, componentSetsMeta };
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
async function exportBoardCommand(figmaUrl, options = {}) {
|
|
2731
|
+
const board = await analyzeBoardCommand(figmaUrl, options);
|
|
2732
|
+
|
|
2733
|
+
if (!board.screens.length) {
|
|
2734
|
+
console.log("");
|
|
2735
|
+
console.log("No board screens detected. Falling back to single-node export.");
|
|
2736
|
+
return exportSingleNodeCommand(figmaUrl, options);
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// Flatten the board once; reuse the lookup for every screen instead of re-flattening per screen.
|
|
2740
|
+
const idToNode = new Map(flattenNodes(board.rootNode).map((node) => [node.id, node]));
|
|
2741
|
+
const componentNameById = board.componentLibrary?.componentNameById || {};
|
|
2742
|
+
const imageFillUrls = await fetchImageFillUrls(board.source.fileKey, board.token);
|
|
2743
|
+
const scale = resolveExportScale(options.scale);
|
|
2744
|
+
|
|
2745
|
+
// Pre-render screen references in small chunks (Figma render-timeouts if too many large images
|
|
2746
|
+
// are requested at once). Anything that fails here is rendered per-screen below.
|
|
2747
|
+
console.log(`Pre-rendering ${board.screens.length} screen reference image(s) at ${scale}x ...`);
|
|
2748
|
+
let referenceUrls = {};
|
|
2749
|
+
try {
|
|
2750
|
+
referenceUrls = await renderReferenceImages(board.source.fileKey, board.screens.map((s) => s.id), board.token, scale);
|
|
2751
|
+
} catch (e) {
|
|
2752
|
+
console.log(`Pre-render skipped (${e.message.split("\n")[0]}); rendering per-screen.`);
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const outputs = [];
|
|
2756
|
+
const iconCache = new Map(); // board-wide: each distinct icon SVG is rendered once, then copied.
|
|
2757
|
+
for (const screen of board.screens) {
|
|
2758
|
+
console.log("");
|
|
2759
|
+
console.log(`Exporting screen ${screen.index}/${board.screens.length}: ${screen.name}`);
|
|
2760
|
+
|
|
2761
|
+
const screenUrl = figmaUrl.replace(/node-id=[^&]+/, `node-id=${screen.id.replaceAll(":", "-")}`);
|
|
2762
|
+
const screenRootNode = idToNode.get(screen.id);
|
|
2763
|
+
|
|
2764
|
+
if (!screenRootNode) {
|
|
2765
|
+
console.log(`Skipping missing screen node: ${screen.id}`);
|
|
2766
|
+
continue;
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
const screenNodeResponse = {
|
|
2770
|
+
...board.nodeResponse,
|
|
2771
|
+
nodes: { [screen.id]: { document: screenRootNode } }
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
const outDir = path.join(board.boardDir, "screens", screen.slug);
|
|
2775
|
+
const outDirName = path.relative(path.join(ROOT, "design-ai"), outDir);
|
|
2776
|
+
|
|
2777
|
+
const result = await exportScreenFromNode({
|
|
2778
|
+
figmaUrl: screenUrl,
|
|
2779
|
+
source: { ...board.source, token: board.token, nodeId: screen.id },
|
|
2780
|
+
rootNode: screenRootNode,
|
|
2781
|
+
nodeResponse: screenNodeResponse,
|
|
2782
|
+
outDir,
|
|
2783
|
+
outDirName,
|
|
2784
|
+
isImplementMode: options.mode === "implement",
|
|
2785
|
+
componentNameById,
|
|
2786
|
+
imageFillUrls,
|
|
2787
|
+
scale,
|
|
2788
|
+
referenceImageUrl: referenceUrls[screen.id] || null,
|
|
2789
|
+
iconCache
|
|
2790
|
+
});
|
|
2791
|
+
|
|
2792
|
+
outputs.push({
|
|
2793
|
+
index: screen.index,
|
|
2794
|
+
name: screen.name,
|
|
2795
|
+
nodeId: screen.id,
|
|
2796
|
+
folder: outDir,
|
|
2797
|
+
prompt: path.join(outDir, "prompt-for-claude.md"),
|
|
2798
|
+
selector: `[data-figma-screen='${screen.id}']`
|
|
2799
|
+
});
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
await writeFile(path.join(board.boardDir, "implementation-plan.md"), buildImplementationPlan(outputs, board), "utf8");
|
|
2803
|
+
|
|
2804
|
+
console.log("");
|
|
2805
|
+
console.log("Board export complete.");
|
|
2806
|
+
console.log(`Implementation plan: ${path.join(board.boardDir, "implementation-plan.md")}`);
|
|
2807
|
+
console.log("");
|
|
2808
|
+
console.log("Next step for VS Code AI:");
|
|
2809
|
+
console.log(`Read and follow: ${path.join(board.boardDir, "implementation-plan.md")}`);
|
|
2810
|
+
|
|
2811
|
+
return { ...board, outputs };
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
function buildImplementationPlan(outputs, board) {
|
|
2815
|
+
const lines = [];
|
|
2816
|
+
lines.push("# Board Implementation Plan", "");
|
|
2817
|
+
lines.push(`Board: **${board.rootNode.name}**`);
|
|
2818
|
+
lines.push(`Detected screens: **${outputs.length}**`, "");
|
|
2819
|
+
lines.push("## Rule", "");
|
|
2820
|
+
lines.push("Implement one screen at a time. Do not merge all screens into one task.");
|
|
2821
|
+
lines.push("Use each screen's `reference.png`, `inventory.md`, `spec.json`, and `prompt-for-claude.md`.");
|
|
2822
|
+
lines.push("");
|
|
2823
|
+
|
|
2824
|
+
const library = board.componentLibrary;
|
|
2825
|
+
if (library && library.list.length) {
|
|
2826
|
+
const buildFirst = library.list.filter((c) => c.usageCount >= 2 || c.screenCount >= 2);
|
|
2827
|
+
lines.push("## Phase 0 — Build shared components first", "");
|
|
2828
|
+
lines.push("These components repeat across screens. Build each **once** as a reusable component, then import it everywhere.");
|
|
2829
|
+
lines.push("Full details: `component-library.md`. Each screen's `inventory.md`/`spec.json` marks where instances appear.", "");
|
|
2830
|
+
if (buildFirst.length) {
|
|
2831
|
+
lines.push("| Order | Component | Build as | Uses | Screens | Variants |");
|
|
2832
|
+
lines.push("|---:|---|---|---:|---:|---|");
|
|
2833
|
+
buildFirst.forEach((c, i) => {
|
|
2834
|
+
const variants = c.isVariantSet ? c.variants.map((v) => v.name).join(", ") : "—";
|
|
2835
|
+
lines.push(`| ${i + 1} | ${c.name} | \`${c.suggestedComponentName}\` | ${c.usageCount} | ${c.screenCount || "—"} | ${variants} |`);
|
|
2836
|
+
});
|
|
2837
|
+
} else {
|
|
2838
|
+
lines.push("_No component is reused across 2+ screens; build components inline per screen as needed._");
|
|
2839
|
+
}
|
|
2840
|
+
lines.push("");
|
|
2841
|
+
lines.push("After Phase 0, implement screens below and reuse the components above instead of re-coding them.", "");
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
lines.push("## Screens", "");
|
|
2845
|
+
lines.push("| # | Screen | Prompt | Selector |");
|
|
2846
|
+
lines.push("|---:|---|---|---|");
|
|
2847
|
+
for (const output of outputs) {
|
|
2848
|
+
lines.push(`| ${output.index} | ${output.name} | \`${output.prompt}\` | \`${output.selector}\` |`);
|
|
2849
|
+
}
|
|
2850
|
+
lines.push("");
|
|
2851
|
+
lines.push("## Suggested VS Code AI workflow", "");
|
|
2852
|
+
for (const output of outputs) {
|
|
2853
|
+
lines.push(`### ${output.index}. ${output.name}`, "");
|
|
2854
|
+
lines.push(`Read and follow: ${output.prompt}`);
|
|
2855
|
+
lines.push(`After implementation, add selector: \`data-figma-screen="${output.nodeId}"\``);
|
|
2856
|
+
lines.push(`Compare command:`);
|
|
2857
|
+
lines.push("");
|
|
2858
|
+
lines.push("```bash");
|
|
2859
|
+
lines.push(`figma-pixel-kit compare "${path.relative(path.join(ROOT, "design-ai"), output.folder)}" --url "http://localhost:3000" --selector "${output.selector}"`);
|
|
2860
|
+
lines.push("```", "");
|
|
2861
|
+
}
|
|
2862
|
+
return lines.join("\n");
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
async function exportCommand(figmaUrl, options = {}) {
|
|
2868
|
+
await initCommand({ silent: true });
|
|
2869
|
+
|
|
2870
|
+
const token = await resolveFigmaToken();
|
|
2871
|
+
if (!token) {
|
|
2872
|
+
throw new Error('Missing Figma token. Run once: figma-pixel-kit token "figd_xxxxxxxxx"');
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
const { rootNode } = await getFigmaRootNode(figmaUrl, token);
|
|
2876
|
+
const screens = detectScreens(rootNode, options);
|
|
2877
|
+
|
|
2878
|
+
// Primary UX: if the selected node contains multiple 1440px frames, treat it as a board.
|
|
2879
|
+
if (screens.length > 1) {
|
|
2880
|
+
console.log(`Auto-detected board: ${screens.length} screen(s) found.`);
|
|
2881
|
+
return exportBoardCommand(figmaUrl, options);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
return exportSingleNodeCommand(figmaUrl, options);
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
// Turn parsed CLI flags into screen-detection options (with the --mobile preset).
|
|
2888
|
+
function normalizeDetectionOptions(parsed = {}, extra = {}) {
|
|
2889
|
+
const options = { ...extra };
|
|
2890
|
+
if (parsed.mobile) {
|
|
2891
|
+
options.screenWidth = 390;
|
|
2892
|
+
options.widthTolerance = 60;
|
|
2893
|
+
options.minHeight = 400;
|
|
2894
|
+
}
|
|
2895
|
+
if (parsed["screen-width"] !== undefined) options.screenWidth = Number(parsed["screen-width"]);
|
|
2896
|
+
if (parsed["width-tolerance"] !== undefined) options.widthTolerance = Number(parsed["width-tolerance"]);
|
|
2897
|
+
if (parsed["min-height"] !== undefined) options.minHeight = Number(parsed["min-height"]);
|
|
2898
|
+
if (parsed["max-height"] !== undefined) options.maxHeight = Number(parsed["max-height"]);
|
|
2899
|
+
if (parsed.scale !== undefined) options.scale = parsed.scale; // validated by resolveExportScale
|
|
2900
|
+
return options;
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
function firstUrlArg(parsed) {
|
|
2904
|
+
return parsed._.find((value) => looksLikeFigmaUrl(value)) || parsed._[0];
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
async function main() {
|
|
2908
|
+
const [, , command, ...rest] = process.argv;
|
|
2909
|
+
|
|
2910
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
2911
|
+
usage();
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
// Primary UX: figma-pixel-kit "https://www.figma.com/..." [--mobile] [--screen-width 390] ...
|
|
2916
|
+
if (looksLikeFigmaUrl(command)) {
|
|
2917
|
+
const parsed = parseArgs(rest);
|
|
2918
|
+
await exportCommand(command, normalizeDetectionOptions(parsed));
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
if (command === "token") {
|
|
2923
|
+
const [token] = rest;
|
|
2924
|
+
await saveTokenCommand(token);
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
if (command === "doctor") {
|
|
2929
|
+
await doctorCommand();
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
if (command === "login") {
|
|
2934
|
+
await loginCommand();
|
|
2935
|
+
return;
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
if (command === "oauth-setup") {
|
|
2939
|
+
const [clientId, clientSecret] = rest;
|
|
2940
|
+
await oauthSetupCommand(clientId, clientSecret);
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
if (command === "logout") {
|
|
2945
|
+
await logoutCommand();
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
|
|
2949
|
+
if (command === "init") {
|
|
2950
|
+
await initCommand();
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
if (["inspect", "analyze", "implement", "export"].includes(command)) {
|
|
2955
|
+
const parsed = parseArgs(rest);
|
|
2956
|
+
const figmaUrl = firstUrlArg(parsed);
|
|
2957
|
+
if (!figmaUrl) {
|
|
2958
|
+
usage();
|
|
2959
|
+
throw new Error("Missing Figma URL.");
|
|
2960
|
+
}
|
|
2961
|
+
if (command === "inspect") {
|
|
2962
|
+
await inspectCommand(figmaUrl);
|
|
2963
|
+
} else if (command === "analyze") {
|
|
2964
|
+
await analyzeBoardCommand(figmaUrl, normalizeDetectionOptions(parsed));
|
|
2965
|
+
} else if (command === "implement") {
|
|
2966
|
+
await exportCommand(figmaUrl, normalizeDetectionOptions(parsed, { mode: "implement" }));
|
|
2967
|
+
} else {
|
|
2968
|
+
await exportCommand(figmaUrl, normalizeDetectionOptions(parsed));
|
|
2969
|
+
}
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
|
|
2973
|
+
if (command === "compare") {
|
|
2974
|
+
const parsed = parseArgs(rest);
|
|
2975
|
+
const [input] = parsed._;
|
|
2976
|
+
if (!input) {
|
|
2977
|
+
usage();
|
|
2978
|
+
throw new Error("Missing output folder/screen id.");
|
|
2979
|
+
}
|
|
2980
|
+
await compareCommand(input, parsed);
|
|
2981
|
+
return;
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
throw new Error(`Unknown command: ${command}`);
|
|
2985
|
+
}
|
|
2986
|
+
|
|
2987
|
+
// Only run the CLI when this file is the entry point, so the pure helpers can be imported in tests.
|
|
2988
|
+
// Compare realpaths: the global `figma-pixel-kit` bin is a symlink, so the raw argv[1] URL
|
|
2989
|
+
// never matches import.meta.url (which is already symlink-resolved). Resolve both before comparing.
|
|
2990
|
+
function isEntryPoint() {
|
|
2991
|
+
if (!process.argv[1]) return false;
|
|
2992
|
+
try {
|
|
2993
|
+
return import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
|
2994
|
+
} catch {
|
|
2995
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
const invokedDirectly = isEntryPoint();
|
|
2999
|
+
if (invokedDirectly) {
|
|
3000
|
+
main().catch((error) => {
|
|
3001
|
+
console.error("\nError:");
|
|
3002
|
+
console.error(error?.message || String(error));
|
|
3003
|
+
// Set FIGMA_PIXEL_KIT_DEBUG=1 to see the full stack trace.
|
|
3004
|
+
if (process.env.FIGMA_PIXEL_KIT_DEBUG && error?.stack) {
|
|
3005
|
+
console.error("\n" + error.stack);
|
|
3006
|
+
}
|
|
3007
|
+
process.exit(1);
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
export {
|
|
3012
|
+
REFERENCE_SCALE,
|
|
3013
|
+
parseFigmaUrl,
|
|
3014
|
+
pascalCase,
|
|
3015
|
+
slugifyName,
|
|
3016
|
+
classifyElement,
|
|
3017
|
+
detectIconLibrary,
|
|
3018
|
+
collectScreenAssets,
|
|
3019
|
+
shouldRenderNode,
|
|
3020
|
+
nodeHasVisiblePaint,
|
|
3021
|
+
flattenNodes,
|
|
3022
|
+
detectScreens,
|
|
3023
|
+
detectComponents,
|
|
3024
|
+
extractComponentLibrary,
|
|
3025
|
+
detectRepeatedPatterns,
|
|
3026
|
+
autoLayoutToCss,
|
|
3027
|
+
layoutChildInfo,
|
|
3028
|
+
buildPropSchema,
|
|
3029
|
+
propsToTs,
|
|
3030
|
+
camelProp,
|
|
3031
|
+
buildSpecJson,
|
|
3032
|
+
buildInventoryMarkdown,
|
|
3033
|
+
buildComponentLibraryMarkdown,
|
|
3034
|
+
buildHtml,
|
|
3035
|
+
buildReferencePreviewHtml,
|
|
3036
|
+
buildVisualTest,
|
|
3037
|
+
detectHotspots,
|
|
3038
|
+
normalizePng,
|
|
3039
|
+
maskRegions,
|
|
3040
|
+
textMaskRects,
|
|
3041
|
+
resolveExportScale
|
|
3042
|
+
};
|