sitezen-mcp 1.0.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/README.md +107 -0
- package/dist/conversion-log.js +67 -0
- package/dist/conversion-rules.md +1361 -0
- package/dist/errors.js +37 -0
- package/dist/figma.js +1369 -0
- package/dist/index.js +37 -0
- package/dist/license.js +121 -0
- package/dist/normalize.js +692 -0
- package/dist/state.js +81 -0
- package/dist/tools-session.js +131 -0
- package/dist/tools.js +1378 -0
- package/dist/validate.js +114 -0
- package/dist/wp-client.js +130 -0
- package/package.json +35 -0
package/dist/figma.js
ADDED
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma REST API helpers — ported from src/lib/figma.ts (platform engine).
|
|
3
|
+
*
|
|
4
|
+
* Why this file exists: the MCP previously relied on Claude in Desktop to
|
|
5
|
+
* fetch Figma data via the figma MCP and figure out which nodes were
|
|
6
|
+
* images, then download SVGs/PNGs separately. That manual orchestration
|
|
7
|
+
* failed in practice — text was dropped, images were missing, vectors
|
|
8
|
+
* were invented. The platform did all of this server-side automatically.
|
|
9
|
+
*
|
|
10
|
+
* This module ports those server-side helpers into the MCP so a single
|
|
11
|
+
* tool call returns everything Claude needs to generate HTML: the text
|
|
12
|
+
* nodes with their exact typography, real rendered image URLs for image
|
|
13
|
+
* nodes, real SVG markup for vector nodes, and the section's bg color.
|
|
14
|
+
*
|
|
15
|
+
* Token: passed per-call (the operator's Figma token, the same one set
|
|
16
|
+
* in the figma MCP env var). The MCP doesn't persist or log it.
|
|
17
|
+
*/
|
|
18
|
+
/** Returns true if the node's absoluteBoundingBox overlaps with the
|
|
19
|
+
* section's bounding box. Used to filter value extractors to only return
|
|
20
|
+
* values whose nodes are visually inside the user-selected section. */
|
|
21
|
+
export function bboxOverlaps(nodeBBox, sectionBBox) {
|
|
22
|
+
if (!nodeBBox || !sectionBBox)
|
|
23
|
+
return false;
|
|
24
|
+
const inset = 0;
|
|
25
|
+
return (nodeBBox.x < sectionBBox.x + sectionBBox.width - inset &&
|
|
26
|
+
nodeBBox.x + nodeBBox.width > sectionBBox.x + inset &&
|
|
27
|
+
nodeBBox.y < sectionBBox.y + sectionBBox.height - inset &&
|
|
28
|
+
nodeBBox.y + nodeBBox.height > sectionBBox.y + inset);
|
|
29
|
+
}
|
|
30
|
+
/** Expanded overlap check — useful for decorative shapes that often sit just
|
|
31
|
+
* outside the section's strict bbox (top/bottom transition waves, blobs that
|
|
32
|
+
* bleed past the edge, decorations whose CENTER is outside but visible part
|
|
33
|
+
* is inside). expandPx adds the same padding on every side. */
|
|
34
|
+
export function bboxOverlapsExpanded(nodeBBox, sectionBBox, expandPx) {
|
|
35
|
+
if (!nodeBBox || !sectionBBox)
|
|
36
|
+
return false;
|
|
37
|
+
const expanded = {
|
|
38
|
+
x: sectionBBox.x - expandPx,
|
|
39
|
+
y: sectionBBox.y - expandPx,
|
|
40
|
+
width: sectionBBox.width + expandPx * 2,
|
|
41
|
+
height: sectionBBox.height + expandPx * 2,
|
|
42
|
+
};
|
|
43
|
+
return bboxOverlaps(nodeBBox, expanded);
|
|
44
|
+
}
|
|
45
|
+
/** Parse a Figma share URL → fileKey + optional nodeId */
|
|
46
|
+
export function parseFigmaUrl(url) {
|
|
47
|
+
try {
|
|
48
|
+
const u = new URL(url);
|
|
49
|
+
const m = u.pathname.match(/\/(?:file|design|proto)\/([a-zA-Z0-9]+)/);
|
|
50
|
+
if (!m)
|
|
51
|
+
return null;
|
|
52
|
+
const nodeId = u.searchParams.get("node-id") || undefined;
|
|
53
|
+
return { fileKey: m[1], nodeId: nodeId ? nodeId.replace("-", ":") : undefined };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Convert a Figma `color` ({r,g,b} in 0..1) to hex (#rrggbb). */
|
|
60
|
+
function rgbToHex(c) {
|
|
61
|
+
if (!c)
|
|
62
|
+
return undefined;
|
|
63
|
+
const b = (v) => Math.max(0, Math.min(255, Math.round((v ?? 0) * 255))).toString(16).padStart(2, "0");
|
|
64
|
+
return "#" + b(c.r || 0) + b(c.g || 0) + b(c.b || 0);
|
|
65
|
+
}
|
|
66
|
+
/** Read first visible SOLID fill on a node as hex, if any. */
|
|
67
|
+
function readSolidFill(node) {
|
|
68
|
+
const fills = (node?.fills || []).filter((f) => f.visible !== false);
|
|
69
|
+
const solid = fills.find((f) => f.type === "SOLID" && f.color);
|
|
70
|
+
return solid ? rgbToHex(solid.color) : undefined;
|
|
71
|
+
}
|
|
72
|
+
/** Read the file's structure.
|
|
73
|
+
*
|
|
74
|
+
* `mode = "shallow"` (depth=3) — only top of the tree, fast. Use for
|
|
75
|
+
* enumerating top-level sections in listRenderableSections / list_section_renders.
|
|
76
|
+
*
|
|
77
|
+
* `mode = "full"` (no depth cap) — the entire layer tree so every TEXT,
|
|
78
|
+
* FRAME, VECTOR, IMAGE-fill node is available to the extractors. Real
|
|
79
|
+
* designs nest content 4-8 levels deep (Page → Frame → Section → Container
|
|
80
|
+
* → Grid → Text). A shallow fetch made everything past level 3 invisible
|
|
81
|
+
* to extractAllTextNodes/extractAllFonts and was the root cause of the
|
|
82
|
+
* bad conversion where text/fonts came back empty even on perfectly
|
|
83
|
+
* editable files. ALWAYS use "full" before prepare_section runs.
|
|
84
|
+
*
|
|
85
|
+
* Larger files take longer here (typically 0.5-3s vs 0.2s shallow) but
|
|
86
|
+
* this is the only way to get the real values. The platform engine
|
|
87
|
+
* has always done a full fetch — we were missing it here. */
|
|
88
|
+
export async function fetchFigmaFile(fileKey, token, mode = "full", opts = {}) {
|
|
89
|
+
// SCOPED FETCH (large-file safety): when a node id is supplied, fetch ONLY
|
|
90
|
+
// that node's subtree via /nodes instead of the whole file. This is the
|
|
91
|
+
// fix for huge multi-design files where the whole-file fetch times out and
|
|
92
|
+
// forces Claude into curl/subagent fallbacks a real user can't do. The
|
|
93
|
+
// node response is wrapped to look like a file so downstream walkers
|
|
94
|
+
// (listRenderableSections etc.) work unchanged.
|
|
95
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
96
|
+
const controller = new AbortController();
|
|
97
|
+
const t = setTimeout(() => controller.abort(), timeoutMs);
|
|
98
|
+
try {
|
|
99
|
+
if (opts.scopeNodeId) {
|
|
100
|
+
const url = `https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(opts.scopeNodeId)}` +
|
|
101
|
+
(mode === "shallow" ? "&depth=3" : "");
|
|
102
|
+
const r = await fetch(url, { headers: { "X-Figma-Token": token }, signal: controller.signal });
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
throw new Error(`Figma node API ${r.status}: ${await r.text()}`);
|
|
105
|
+
const j = await r.json();
|
|
106
|
+
const doc = j.nodes?.[opts.scopeNodeId]?.document;
|
|
107
|
+
if (!doc)
|
|
108
|
+
throw new Error(`Figma node ${opts.scopeNodeId} not found in file`);
|
|
109
|
+
// Wrap as a synthetic file: document.children = [the scoped node],
|
|
110
|
+
// so listRenderableSections sees it as a top-level frame.
|
|
111
|
+
return {
|
|
112
|
+
name: j.name,
|
|
113
|
+
document: { id: "synthetic-root", type: "DOCUMENT", children: [{ ...doc }] },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const depthParam = mode === "shallow" ? "?depth=3" : "";
|
|
117
|
+
const r = await fetch(`https://api.figma.com/v1/files/${fileKey}${depthParam}`, {
|
|
118
|
+
headers: { "X-Figma-Token": token },
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
});
|
|
121
|
+
if (!r.ok)
|
|
122
|
+
throw new Error(`Figma file API ${r.status}: ${await r.text()}`);
|
|
123
|
+
return r.json();
|
|
124
|
+
}
|
|
125
|
+
catch (e) {
|
|
126
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
127
|
+
throw new Error(`FIGMA_FILE_TIMEOUT: the Figma file fetch took longer than ${Math.round(timeoutMs / 1000)}s. The file is very large. Paste a Figma URL whose node-id points at (or near) the section you want, so the MCP can fetch just that part instead of the whole file.`);
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
clearTimeout(t);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Walk-up logic — ported from platform's findNodeAncestors + pickBestScope.
|
|
136
|
+
*
|
|
137
|
+
* Handles the common user mistake: pasting a Figma link that points at a
|
|
138
|
+
* sub-element (a logo, a button) when they meant the section that contains
|
|
139
|
+
* it. We walk up the ancestor chain from the user's node to find a
|
|
140
|
+
* section-sized ancestor (600px ≤ height ≤ 3500px is the typical single-
|
|
141
|
+
* section range). If found, conversion uses THAT ancestor; otherwise we
|
|
142
|
+
* trust the user's original node-id.
|
|
143
|
+
*
|
|
144
|
+
* Returns the effective node id to convert (possibly different from input). */
|
|
145
|
+
export async function resolveSectionNodeId(fileKey, requestedNodeId, token) {
|
|
146
|
+
// Fetch the user's node first to see its size
|
|
147
|
+
const nodeResp = await fetchFigmaNode(fileKey, requestedNodeId, token);
|
|
148
|
+
const node = nodeResp?.nodes?.[requestedNodeId]?.document;
|
|
149
|
+
if (!node)
|
|
150
|
+
return { effectiveNodeId: requestedNodeId, walkedUp: false, reason: "node not found" };
|
|
151
|
+
const bbox = node.absoluteBoundingBox;
|
|
152
|
+
const height = bbox?.height || 0;
|
|
153
|
+
// If node is already section-sized (≥400px tall), trust it.
|
|
154
|
+
if (height >= 400) {
|
|
155
|
+
return { effectiveNodeId: requestedNodeId, walkedUp: false };
|
|
156
|
+
}
|
|
157
|
+
// Node looks like a sub-element. Fetch the file tree and walk up.
|
|
158
|
+
let fileTree;
|
|
159
|
+
try {
|
|
160
|
+
fileTree = await fetchFigmaFile(fileKey, token);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Can't walk up without the tree — use what user gave.
|
|
164
|
+
return { effectiveNodeId: requestedNodeId, walkedUp: false, reason: "tree fetch failed" };
|
|
165
|
+
}
|
|
166
|
+
// Find ancestor chain (innermost LAST)
|
|
167
|
+
function walk(n, path) {
|
|
168
|
+
if (!n)
|
|
169
|
+
return null;
|
|
170
|
+
const here = [...path, n];
|
|
171
|
+
if (n.id === requestedNodeId)
|
|
172
|
+
return here;
|
|
173
|
+
if (Array.isArray(n.children)) {
|
|
174
|
+
for (const c of n.children) {
|
|
175
|
+
const r = walk(c, here);
|
|
176
|
+
if (r)
|
|
177
|
+
return r;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const ancestors = walk(fileTree?.document, []) || [];
|
|
183
|
+
if (ancestors.length < 2) {
|
|
184
|
+
return { effectiveNodeId: requestedNodeId, walkedUp: false, reason: "no ancestors" };
|
|
185
|
+
}
|
|
186
|
+
// Walk from immediate parent UP, picking the smallest ancestor in the
|
|
187
|
+
// section-sized range (600-3500px tall). Skip the leaf itself.
|
|
188
|
+
for (let i = ancestors.length - 2; i >= 0; i--) {
|
|
189
|
+
const a = ancestors[i];
|
|
190
|
+
const ah = a?.absoluteBoundingBox?.height || 0;
|
|
191
|
+
if (ah >= 600 && ah <= 3500) {
|
|
192
|
+
return {
|
|
193
|
+
effectiveNodeId: a.id,
|
|
194
|
+
walkedUp: true,
|
|
195
|
+
reason: `picked section-sized ancestor "${a.name}" (${Math.round(ah)}px tall) — your URL pointed at a smaller sub-element (${Math.round(height)}px)`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// No section-sized ancestor — use the original node.
|
|
200
|
+
return { effectiveNodeId: requestedNodeId, walkedUp: false, reason: "no section-sized ancestor in range" };
|
|
201
|
+
}
|
|
202
|
+
/** Fetch one specific node with full descendants. */
|
|
203
|
+
export async function fetchFigmaNode(fileKey, nodeId, token) {
|
|
204
|
+
const r = await fetch(`https://api.figma.com/v1/files/${fileKey}/nodes?ids=${encodeURIComponent(nodeId)}`, { headers: { "X-Figma-Token": token } });
|
|
205
|
+
if (!r.ok)
|
|
206
|
+
throw new Error(`Figma node API ${r.status}: ${await r.text()}`);
|
|
207
|
+
return r.json();
|
|
208
|
+
}
|
|
209
|
+
/** Fetch ONE node's full subtree and wrap it as a synthetic "file" so the
|
|
210
|
+
* existing extractors (which expect fileJson.document with .children) can
|
|
211
|
+
* walk it unchanged.
|
|
212
|
+
*
|
|
213
|
+
* This is the fast + complete path for prepare_section when we know the
|
|
214
|
+
* section ID up-front: instead of pulling the whole file deeply (slow on
|
|
215
|
+
* designs with many pages or huge canvases — can time out), we pull just
|
|
216
|
+
* the target section. Same depth, fraction of the bytes, no timeout risk.
|
|
217
|
+
*
|
|
218
|
+
* Returns null when the node isn't found in the response. */
|
|
219
|
+
export async function fetchFigmaSubtreeAsFile(fileKey, nodeId, token) {
|
|
220
|
+
const resp = await fetchFigmaNode(fileKey, nodeId, token);
|
|
221
|
+
const nodeDoc = resp?.nodes?.[nodeId]?.document;
|
|
222
|
+
if (!nodeDoc)
|
|
223
|
+
return null;
|
|
224
|
+
// Wrap so extractors walking fileJson.document.children see the node's
|
|
225
|
+
// subtree as the "document". Carries the file's name through for log/UX.
|
|
226
|
+
return {
|
|
227
|
+
name: resp?.name,
|
|
228
|
+
document: {
|
|
229
|
+
// Synthesise a root container whose child is the node — keeps the
|
|
230
|
+
// walk semantics identical to a full-file fetch.
|
|
231
|
+
id: "synthetic-root",
|
|
232
|
+
name: "synthetic-root",
|
|
233
|
+
type: "DOCUMENT",
|
|
234
|
+
children: [nodeDoc],
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/** Fetch rendered images for given node ids. Returns map of nodeId → URL.
|
|
239
|
+
* format='png' for content photos, 'svg' for vectors/icons.
|
|
240
|
+
*
|
|
241
|
+
* Resilient to large files. Three protections so the call never times out
|
|
242
|
+
* silently or returns a partial result:
|
|
243
|
+
* 1. BATCHING — Figma's /v1/images can handle a comma-joined list but slows
|
|
244
|
+
* dramatically past ~10 nodes. We chunk into batches of 8 and request in
|
|
245
|
+
* parallel; each batch only blocks on its own slowest render.
|
|
246
|
+
* 2. PER-CALL TIMEOUT + RETRY — each batch is given 45s via AbortController;
|
|
247
|
+
* if it times out we retry with exponential backoff (1.5s → 4s → 8s),
|
|
248
|
+
* up to 3 attempts.
|
|
249
|
+
* 3. SCALE FALLBACK — if a batch still fails at the requested scale, we
|
|
250
|
+
* downshift one step (3 → 2 → 1) and retry once. Lower scale = smaller
|
|
251
|
+
* PNG = much faster server-side rendering. The image is still usable;
|
|
252
|
+
* caller can re-fetch high-res later if quality matters.
|
|
253
|
+
*
|
|
254
|
+
* Returns the COMBINED map across all batches. Any node that couldn't be
|
|
255
|
+
* rendered after all retries is set to null so caller can detect partial
|
|
256
|
+
* data via Object.values(map).some(v => v == null). */
|
|
257
|
+
export async function fetchRenderedImages(fileKey, nodeIds, token, format = "png", scale = 2) {
|
|
258
|
+
if (nodeIds.length === 0)
|
|
259
|
+
return {};
|
|
260
|
+
const BATCH_SIZE = 8;
|
|
261
|
+
const TIMEOUT_MS = 45_000;
|
|
262
|
+
const MAX_ATTEMPTS = 3;
|
|
263
|
+
async function fetchBatch(ids, useScale) {
|
|
264
|
+
const url = `https://api.figma.com/v1/images/${fileKey}?ids=${encodeURIComponent(ids.join(","))}&format=${format}&scale=${useScale}`;
|
|
265
|
+
const controller = new AbortController();
|
|
266
|
+
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
267
|
+
try {
|
|
268
|
+
const r = await fetch(url, {
|
|
269
|
+
headers: { "X-Figma-Token": token },
|
|
270
|
+
signal: controller.signal,
|
|
271
|
+
});
|
|
272
|
+
if (!r.ok)
|
|
273
|
+
throw new Error(`Figma images API ${r.status}: ${await r.text()}`);
|
|
274
|
+
const j = await r.json();
|
|
275
|
+
return j.images || {};
|
|
276
|
+
}
|
|
277
|
+
finally {
|
|
278
|
+
clearTimeout(t);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function batchWithRetry(ids) {
|
|
282
|
+
let lastErr;
|
|
283
|
+
let currentScale = scale;
|
|
284
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
285
|
+
try {
|
|
286
|
+
return await fetchBatch(ids, currentScale);
|
|
287
|
+
}
|
|
288
|
+
catch (e) {
|
|
289
|
+
lastErr = e;
|
|
290
|
+
// After the second failure, drop scale by one (cheaper render).
|
|
291
|
+
if (attempt === 2 && currentScale > 1)
|
|
292
|
+
currentScale--;
|
|
293
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
294
|
+
const backoff = attempt === 1 ? 1500 : attempt === 2 ? 4000 : 8000;
|
|
295
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Final fallback: return nulls for these ids so caller can decide what
|
|
300
|
+
// to do, rather than throwing and losing the WHOLE map.
|
|
301
|
+
const nullMap = {};
|
|
302
|
+
for (const id of ids)
|
|
303
|
+
nullMap[id] = null;
|
|
304
|
+
// Surface the underlying error to stderr so it shows in Claude Desktop logs.
|
|
305
|
+
process.stderr.write(`[sitezen-mcp] fetchRenderedImages batch failed after ${MAX_ATTEMPTS} attempts: ` +
|
|
306
|
+
(lastErr instanceof Error ? lastErr.message : String(lastErr)) + "\n");
|
|
307
|
+
return nullMap;
|
|
308
|
+
}
|
|
309
|
+
const batches = [];
|
|
310
|
+
for (let i = 0; i < nodeIds.length; i += BATCH_SIZE) {
|
|
311
|
+
batches.push(nodeIds.slice(i, i + BATCH_SIZE));
|
|
312
|
+
}
|
|
313
|
+
const results = await Promise.all(batches.map(batchWithRetry));
|
|
314
|
+
return Object.assign({}, ...results);
|
|
315
|
+
}
|
|
316
|
+
/** Walk a Figma node tree and collect all TEXT nodes with their typography.
|
|
317
|
+
* Used to build the text_nodes array passed to enforceFigmaTextStyles. */
|
|
318
|
+
export function extractTextNodes(node, out = []) {
|
|
319
|
+
if (!node)
|
|
320
|
+
return out;
|
|
321
|
+
if (node.type === "TEXT" && node.characters) {
|
|
322
|
+
const style = node.style || {};
|
|
323
|
+
const textFill = (node.fills || []).find((f) => f.type === "SOLID" && f.color && f.visible !== false);
|
|
324
|
+
out.push({
|
|
325
|
+
text: String(node.characters).trim(),
|
|
326
|
+
fontSize: typeof style.fontSize === "number" ? style.fontSize : undefined,
|
|
327
|
+
fontWeight: typeof style.fontWeight === "number" ? style.fontWeight : undefined,
|
|
328
|
+
color: textFill ? rgbToHex(textFill.color) : undefined,
|
|
329
|
+
fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
if (Array.isArray(node.children)) {
|
|
333
|
+
for (const child of node.children)
|
|
334
|
+
extractTextNodes(child, out);
|
|
335
|
+
}
|
|
336
|
+
return out;
|
|
337
|
+
}
|
|
338
|
+
/** Walk a Figma node tree and identify nodes that should be rendered as
|
|
339
|
+
* images/vectors in the output HTML.
|
|
340
|
+
*
|
|
341
|
+
* • IMAGE-fill nodes → real photos, hero shots, avatars (PNG)
|
|
342
|
+
* • VECTOR / GROUP-of-vectors with no text → decorative shapes, icons,
|
|
343
|
+
* logos (SVG)
|
|
344
|
+
* • Plain frame containers are skipped (they're layout, not images) */
|
|
345
|
+
export function collectImageNodes(node, depth = 0) {
|
|
346
|
+
const out = [];
|
|
347
|
+
if (!node || depth > 8)
|
|
348
|
+
return out;
|
|
349
|
+
// IMAGE fill = treat as a photo (PNG export)
|
|
350
|
+
const hasImageFill = (node.fills || []).some((f) => f.type === "IMAGE" && f.visible !== false);
|
|
351
|
+
if (hasImageFill) {
|
|
352
|
+
out.push({ id: node.id, name: node.name || "image", kind: "image" });
|
|
353
|
+
return out; // don't drill into image-fill nodes
|
|
354
|
+
}
|
|
355
|
+
// Vector primitives at any depth — export as SVG
|
|
356
|
+
const VECTOR_TYPES = new Set([
|
|
357
|
+
"VECTOR", "BOOLEAN_OPERATION", "LINE", "REGULAR_POLYGON",
|
|
358
|
+
"STAR", "ELLIPSE", "RECTANGLE_WITH_VECTOR",
|
|
359
|
+
]);
|
|
360
|
+
if (VECTOR_TYPES.has(node.type)) {
|
|
361
|
+
// Skip rectangles that are just colored fills (the SVG would be redundant)
|
|
362
|
+
// — only emit a vector if it has a vectorPath / fillGeometry that's
|
|
363
|
+
// non-trivial. For simple boxes, Claude can use a styled <div>.
|
|
364
|
+
const hasGeometry = (Array.isArray(node.fillGeometry) && node.fillGeometry.length > 0) ||
|
|
365
|
+
(Array.isArray(node.strokeGeometry) && node.strokeGeometry.length > 0);
|
|
366
|
+
if (hasGeometry) {
|
|
367
|
+
out.push({ id: node.id, name: node.name || "vector", kind: "vector" });
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Group / Frame containing ONLY vectors (no text, no image fills) — render the
|
|
372
|
+
// whole group as ONE SVG so paths stay together as the designer intended.
|
|
373
|
+
if ((node.type === "GROUP" || node.type === "FRAME") && Array.isArray(node.children)) {
|
|
374
|
+
const hasText = subtreeHasType(node, "TEXT");
|
|
375
|
+
const hasImageFillInSubtree = subtreeHasImageFill(node);
|
|
376
|
+
const onlyVectors = !hasText && !hasImageFillInSubtree &&
|
|
377
|
+
node.children.length > 0 &&
|
|
378
|
+
node.children.every((c) => VECTOR_TYPES.has(c.type) || c.type === "GROUP");
|
|
379
|
+
if (onlyVectors) {
|
|
380
|
+
out.push({ id: node.id, name: node.name || "vector group", kind: "vector" });
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Otherwise, recurse
|
|
385
|
+
if (Array.isArray(node.children)) {
|
|
386
|
+
for (const child of node.children)
|
|
387
|
+
out.push(...collectImageNodes(child, depth + 1));
|
|
388
|
+
}
|
|
389
|
+
// Dedupe by id, cap at 12 to keep response sizes sane
|
|
390
|
+
const seen = new Set();
|
|
391
|
+
return out.filter((r) => (seen.has(r.id) ? false : (seen.add(r.id), true))).slice(0, 12);
|
|
392
|
+
}
|
|
393
|
+
function subtreeHasType(node, type) {
|
|
394
|
+
if (!node)
|
|
395
|
+
return false;
|
|
396
|
+
if (node.type === type)
|
|
397
|
+
return true;
|
|
398
|
+
if (Array.isArray(node.children)) {
|
|
399
|
+
return node.children.some((c) => subtreeHasType(c, type));
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
function subtreeHasImageFill(node) {
|
|
404
|
+
if (!node)
|
|
405
|
+
return false;
|
|
406
|
+
if ((node.fills || []).some((f) => f.type === "IMAGE"))
|
|
407
|
+
return true;
|
|
408
|
+
if (Array.isArray(node.children)) {
|
|
409
|
+
return node.children.some((c) => subtreeHasImageFill(c));
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
/* ─── Whole-file value extractors ──────────────────────────────────────────
|
|
414
|
+
*
|
|
415
|
+
* These walk the ENTIRE Figma file (every page, every frame, every
|
|
416
|
+
* descendant) and return flat collections of VALUES — text content,
|
|
417
|
+
* colors, fonts, image refs — without preserving the layer hierarchy.
|
|
418
|
+
*
|
|
419
|
+
* Why whole-file instead of node-scoped: the universal rule (§0.3.AL +
|
|
420
|
+
* §0.3.A in CONVERSION_RULES.md) is that the USER'S SCREENSHOT is the
|
|
421
|
+
* ground truth for which section to convert. The model never picks
|
|
422
|
+
* "which frame" from the Figma layer panel because real user files
|
|
423
|
+
* have messy layer structures ("Frame 32", "Group 12") that don't map
|
|
424
|
+
* cleanly to visible sections. The model looks at the screenshot for
|
|
425
|
+
* layout, and uses these flat value collections for exact text/colors/
|
|
426
|
+
* fonts/images — matching them to what's visible in the screenshot.
|
|
427
|
+
*
|
|
428
|
+
* Rule of authority:
|
|
429
|
+
* - Screenshot → visual layout, hierarchy, spacing
|
|
430
|
+
* - Figma JSON → exact text content, hex colors, font names+weights, image refs
|
|
431
|
+
* - Plugin specs → sz-* markup conventions
|
|
432
|
+
*/
|
|
433
|
+
/** Walk every page, every frame, every descendant — collect TEXT nodes.
|
|
434
|
+
* If sectionBBox is provided, only returns text nodes whose absoluteBoundingBox
|
|
435
|
+
* overlaps with the given section region (Option C — section-scoped values). */
|
|
436
|
+
export function extractAllTextNodes(fileJson, sectionBBox) {
|
|
437
|
+
const out = [];
|
|
438
|
+
function walk(node) {
|
|
439
|
+
if (!node)
|
|
440
|
+
return;
|
|
441
|
+
if (node.type === "TEXT" && node.characters) {
|
|
442
|
+
// Section scoping: skip text nodes outside the section's bbox
|
|
443
|
+
if (sectionBBox && !bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
444
|
+
// still recurse — children could theoretically be inside (rare)
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
const style = node.style || {};
|
|
448
|
+
const textFill = (node.fills || []).find((f) => f.type === "SOLID" && f.color && f.visible !== false);
|
|
449
|
+
// letterSpacing — Figma stores as {unit:"PIXELS"|"PERCENT", value:n}
|
|
450
|
+
// OR directly as a number (legacy). Normalise to px.
|
|
451
|
+
let letterSpacing;
|
|
452
|
+
if (typeof style.letterSpacing === "number") {
|
|
453
|
+
letterSpacing = style.letterSpacing;
|
|
454
|
+
}
|
|
455
|
+
else if (style.letterSpacing && typeof style.letterSpacing === "object") {
|
|
456
|
+
const ls = style.letterSpacing;
|
|
457
|
+
if (ls.unit === "PERCENT" && typeof style.fontSize === "number") {
|
|
458
|
+
letterSpacing = (style.fontSize * (ls.value || 0)) / 100;
|
|
459
|
+
}
|
|
460
|
+
else if (typeof ls.value === "number") {
|
|
461
|
+
letterSpacing = ls.value;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// lineHeight — Figma exposes lineHeightPx (resolved) most often.
|
|
465
|
+
// lineHeightUnit can be "AUTO" — meaning use the font's default.
|
|
466
|
+
let lineHeight;
|
|
467
|
+
let lineHeightUnit;
|
|
468
|
+
if (style.lineHeightUnit === "AUTO") {
|
|
469
|
+
lineHeightUnit = "auto";
|
|
470
|
+
}
|
|
471
|
+
else if (typeof style.lineHeightPx === "number") {
|
|
472
|
+
lineHeight = style.lineHeightPx;
|
|
473
|
+
lineHeightUnit = "px";
|
|
474
|
+
}
|
|
475
|
+
else if (typeof style.lineHeightPercent === "number" && typeof style.fontSize === "number") {
|
|
476
|
+
lineHeight = (style.fontSize * style.lineHeightPercent) / 100;
|
|
477
|
+
lineHeightUnit = "px";
|
|
478
|
+
}
|
|
479
|
+
// textDecoration / textCase / textAlign — direct Figma fields.
|
|
480
|
+
const decoMap = {
|
|
481
|
+
UNDERLINE: "underline",
|
|
482
|
+
STRIKETHROUGH: "strikethrough",
|
|
483
|
+
};
|
|
484
|
+
const caseMap = {
|
|
485
|
+
ORIGINAL: "original",
|
|
486
|
+
UPPER: "upper",
|
|
487
|
+
LOWER: "lower",
|
|
488
|
+
TITLE: "title",
|
|
489
|
+
SMALL_CAPS: "small_caps",
|
|
490
|
+
SMALL_CAPS_FORCED: "small_caps",
|
|
491
|
+
};
|
|
492
|
+
const alignMap = {
|
|
493
|
+
LEFT: "left",
|
|
494
|
+
CENTER: "center",
|
|
495
|
+
RIGHT: "right",
|
|
496
|
+
JUSTIFIED: "justify",
|
|
497
|
+
};
|
|
498
|
+
const nodeOpacity = typeof node.opacity === "number" && node.opacity < 1
|
|
499
|
+
? node.opacity
|
|
500
|
+
: undefined;
|
|
501
|
+
out.push({
|
|
502
|
+
text: String(node.characters).trim(),
|
|
503
|
+
fontSize: typeof style.fontSize === "number" ? style.fontSize : undefined,
|
|
504
|
+
fontWeight: typeof style.fontWeight === "number" ? style.fontWeight : undefined,
|
|
505
|
+
color: textFill ? rgbToHex(textFill.color) : undefined,
|
|
506
|
+
fontFamily: typeof style.fontFamily === "string" ? style.fontFamily : undefined,
|
|
507
|
+
letterSpacing,
|
|
508
|
+
lineHeight,
|
|
509
|
+
lineHeightUnit,
|
|
510
|
+
textDecoration: decoMap[style.textDecoration],
|
|
511
|
+
textCase: caseMap[style.textCase],
|
|
512
|
+
textAlign: alignMap[style.textAlignHorizontal],
|
|
513
|
+
opacity: nodeOpacity,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (Array.isArray(node.children)) {
|
|
518
|
+
for (const child of node.children)
|
|
519
|
+
walk(child);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
walk(fileJson?.document);
|
|
523
|
+
// Dedupe by text content — same string appearing twice means same value, no need to list both
|
|
524
|
+
const seen = new Set();
|
|
525
|
+
return out.filter((n) => {
|
|
526
|
+
const key = (n.text || "").toLowerCase();
|
|
527
|
+
if (key.length < 1 || seen.has(key))
|
|
528
|
+
return false;
|
|
529
|
+
seen.add(key);
|
|
530
|
+
return true;
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
/** Walk every node — collect FULL gradient definitions (type + angle + stops)
|
|
534
|
+
* as ready-to-paste CSS strings. Without this, gradients were lost (we only
|
|
535
|
+
* captured their colors, not their shape/angle) and Claude had to invent
|
|
536
|
+
* the gradient direction — usually wrong. Gradient backgrounds were the
|
|
537
|
+
* single biggest cause of "bg looks off" complaints.
|
|
538
|
+
*
|
|
539
|
+
* Returns CSS-ready strings like:
|
|
540
|
+
* "linear-gradient(135deg, #ff0080 0%, #7928ca 100%)"
|
|
541
|
+
* "radial-gradient(circle at 30% 30%, #00ffd5 0%, #0099ff 100%)"
|
|
542
|
+
* "conic-gradient(from 90deg, #ff5733 0%, #ffc733 100%)"
|
|
543
|
+
*
|
|
544
|
+
* Plus the actual node bbox so Claude can match each gradient to where it
|
|
545
|
+
* appears in the design.
|
|
546
|
+
*/
|
|
547
|
+
export function extractAllGradients(fileJson, sectionBBox) {
|
|
548
|
+
const out = [];
|
|
549
|
+
function paintToCss(paint) {
|
|
550
|
+
if (!paint || paint.visible === false)
|
|
551
|
+
return null;
|
|
552
|
+
const t = paint.type;
|
|
553
|
+
if (!t || !t.startsWith("GRADIENT"))
|
|
554
|
+
return null;
|
|
555
|
+
const stops = (paint.gradientStops || []).map((s) => {
|
|
556
|
+
const hex = rgbToHex(s.color) || "#000000";
|
|
557
|
+
const opacity = typeof s.color?.a === "number" ? s.color.a : 1;
|
|
558
|
+
const pct = Math.round((s.position || 0) * 100);
|
|
559
|
+
const colorWithAlpha = opacity < 1
|
|
560
|
+
? `${hex}${Math.round(opacity * 255).toString(16).padStart(2, "0")}`
|
|
561
|
+
: hex;
|
|
562
|
+
return `${colorWithAlpha} ${pct}%`;
|
|
563
|
+
}).join(", ");
|
|
564
|
+
if (!stops)
|
|
565
|
+
return null;
|
|
566
|
+
// gradientHandlePositions: [start, end, width] — points in 0..1 unit space.
|
|
567
|
+
const handles = paint.gradientHandlePositions || [];
|
|
568
|
+
if (t === "GRADIENT_LINEAR") {
|
|
569
|
+
// Compute angle from start→end handle. CSS angle: 0deg = up, 90deg = right.
|
|
570
|
+
// Figma: 0,0 = top-left; 1,1 = bottom-right.
|
|
571
|
+
const dx = (handles[1]?.x ?? 1) - (handles[0]?.x ?? 0);
|
|
572
|
+
const dy = (handles[1]?.y ?? 1) - (handles[0]?.y ?? 0);
|
|
573
|
+
const angle = Math.round((Math.atan2(dx, -dy) * 180) / Math.PI);
|
|
574
|
+
return { css: `linear-gradient(${angle}deg, ${stops})`, type: "linear" };
|
|
575
|
+
}
|
|
576
|
+
if (t === "GRADIENT_RADIAL") {
|
|
577
|
+
const cx = Math.round((handles[0]?.x ?? 0.5) * 100);
|
|
578
|
+
const cy = Math.round((handles[0]?.y ?? 0.5) * 100);
|
|
579
|
+
return { css: `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`, type: "radial" };
|
|
580
|
+
}
|
|
581
|
+
if (t === "GRADIENT_ANGULAR") {
|
|
582
|
+
const cx = Math.round((handles[0]?.x ?? 0.5) * 100);
|
|
583
|
+
const cy = Math.round((handles[0]?.y ?? 0.5) * 100);
|
|
584
|
+
return { css: `conic-gradient(at ${cx}% ${cy}%, ${stops})`, type: "angular" };
|
|
585
|
+
}
|
|
586
|
+
if (t === "GRADIENT_DIAMOND") {
|
|
587
|
+
// CSS has no direct diamond gradient — closest is a radial with
|
|
588
|
+
// closest-side. Mark the type so Claude knows it's approximate.
|
|
589
|
+
const cx = Math.round((handles[0]?.x ?? 0.5) * 100);
|
|
590
|
+
const cy = Math.round((handles[0]?.y ?? 0.5) * 100);
|
|
591
|
+
return { css: `radial-gradient(closest-side at ${cx}% ${cy}%, ${stops})`, type: "diamond" };
|
|
592
|
+
}
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
function walk(node) {
|
|
596
|
+
if (!node)
|
|
597
|
+
return;
|
|
598
|
+
if (!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
599
|
+
const fills = Array.isArray(node.fills) ? node.fills : [];
|
|
600
|
+
for (const fill of fills) {
|
|
601
|
+
const grad = paintToCss(fill);
|
|
602
|
+
if (grad && node.absoluteBoundingBox) {
|
|
603
|
+
out.push({
|
|
604
|
+
css: grad.css,
|
|
605
|
+
type: grad.type,
|
|
606
|
+
bbox: {
|
|
607
|
+
x: Math.round(node.absoluteBoundingBox.x),
|
|
608
|
+
y: Math.round(node.absoluteBoundingBox.y),
|
|
609
|
+
width: Math.round(node.absoluteBoundingBox.width),
|
|
610
|
+
height: Math.round(node.absoluteBoundingBox.height),
|
|
611
|
+
},
|
|
612
|
+
node_name: node.name,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (Array.isArray(node.children)) {
|
|
618
|
+
for (const c of node.children)
|
|
619
|
+
walk(c);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
walk(fileJson?.document);
|
|
623
|
+
// Dedupe by css string — same gradient on many nodes collapses to one entry.
|
|
624
|
+
const seen = new Set();
|
|
625
|
+
return out.filter((g) => seen.has(g.css) ? false : (seen.add(g.css), true));
|
|
626
|
+
}
|
|
627
|
+
/** Walk every node in the file — collect every unique hex color in fills/strokes.
|
|
628
|
+
* If sectionBBox is provided, only includes colors from nodes inside that region. */
|
|
629
|
+
export function extractAllColors(fileJson, sectionBBox) {
|
|
630
|
+
const seen = new Set();
|
|
631
|
+
function collectFromFills(fills) {
|
|
632
|
+
if (!Array.isArray(fills))
|
|
633
|
+
return;
|
|
634
|
+
for (const f of fills) {
|
|
635
|
+
if (f.visible === false)
|
|
636
|
+
continue;
|
|
637
|
+
if (f.type === "SOLID" && f.color) {
|
|
638
|
+
const hex = rgbToHex(f.color);
|
|
639
|
+
if (hex)
|
|
640
|
+
seen.add(hex.toUpperCase());
|
|
641
|
+
}
|
|
642
|
+
if (f.type?.startsWith("GRADIENT") && Array.isArray(f.gradientStops)) {
|
|
643
|
+
for (const stop of f.gradientStops) {
|
|
644
|
+
const hex = rgbToHex(stop.color);
|
|
645
|
+
if (hex)
|
|
646
|
+
seen.add(hex.toUpperCase());
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function walk(node) {
|
|
652
|
+
if (!node)
|
|
653
|
+
return;
|
|
654
|
+
if (!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
655
|
+
collectFromFills(node.fills);
|
|
656
|
+
collectFromFills(node.strokes);
|
|
657
|
+
}
|
|
658
|
+
if (Array.isArray(node.children)) {
|
|
659
|
+
for (const child of node.children)
|
|
660
|
+
walk(child);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
walk(fileJson?.document);
|
|
664
|
+
return Array.from(seen);
|
|
665
|
+
}
|
|
666
|
+
/** Walk every TEXT node — collect every unique font family.
|
|
667
|
+
* Scoped to sectionBBox when provided. */
|
|
668
|
+
export function extractAllFonts(fileJson, sectionBBox) {
|
|
669
|
+
const map = new Map();
|
|
670
|
+
function walk(node) {
|
|
671
|
+
if (!node)
|
|
672
|
+
return;
|
|
673
|
+
if (node.type === "TEXT" && node.style?.fontFamily) {
|
|
674
|
+
if (!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
675
|
+
const fam = String(node.style.fontFamily);
|
|
676
|
+
const weight = typeof node.style.fontWeight === "number" ? node.style.fontWeight : 400;
|
|
677
|
+
if (!map.has(fam))
|
|
678
|
+
map.set(fam, new Set());
|
|
679
|
+
map.get(fam).add(weight);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (Array.isArray(node.children)) {
|
|
683
|
+
for (const child of node.children)
|
|
684
|
+
walk(child);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
walk(fileJson?.document);
|
|
688
|
+
return Array.from(map.entries()).map(([family, weights]) => ({
|
|
689
|
+
family,
|
|
690
|
+
weights: Array.from(weights).sort((a, b) => a - b),
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
/** Walk every node — collect all nodes with IMAGE fills (real photos).
|
|
694
|
+
* Scoped to sectionBBox when provided. */
|
|
695
|
+
export function extractAllImageNodes(fileJson, sectionBBox) {
|
|
696
|
+
const out = [];
|
|
697
|
+
function walk(node) {
|
|
698
|
+
if (!node)
|
|
699
|
+
return;
|
|
700
|
+
const hasImageFill = (node.fills || []).some((f) => f.type === "IMAGE" && f.visible !== false);
|
|
701
|
+
if (hasImageFill) {
|
|
702
|
+
if (!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
703
|
+
const bbox = node.absoluteBoundingBox || {};
|
|
704
|
+
out.push({
|
|
705
|
+
id: node.id,
|
|
706
|
+
name: node.name || "image",
|
|
707
|
+
width: Math.round(bbox.width || 0),
|
|
708
|
+
height: Math.round(bbox.height || 0),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
return; // don't drill into image-fill nodes (children would be cropped)
|
|
712
|
+
}
|
|
713
|
+
if (Array.isArray(node.children)) {
|
|
714
|
+
for (const child of node.children)
|
|
715
|
+
walk(child);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
walk(fileJson?.document);
|
|
719
|
+
// Sort by area (largest first) so the model's choices for hero photos come first
|
|
720
|
+
return out.sort((a, b) => b.width * b.height - a.width * a.height);
|
|
721
|
+
}
|
|
722
|
+
/** Walk every node — collect LAYOUT VALUES from FRAME / COMPONENT / INSTANCE
|
|
723
|
+
* nodes that use autolayout. We grab `paddingTop/Right/Bottom/Left`,
|
|
724
|
+
* `itemSpacing` (the gap), `layoutMode` (HORIZONTAL = flex-row, VERTICAL =
|
|
725
|
+
* flex-column, NONE = no autolayout), `primaryAxisAlignItems` (justify-
|
|
726
|
+
* content equivalent), `counterAxisAlignItems` (align-items equivalent),
|
|
727
|
+
* and the node's bbox dimensions.
|
|
728
|
+
*
|
|
729
|
+
* These are VALUES — just numbers on each node. They don't depend on the
|
|
730
|
+
* layer being named well, don't depend on the layer hierarchy being clean,
|
|
731
|
+
* don't carry parent/child relationships. They're the same kind of data
|
|
732
|
+
* we already extract for font-size and color, just for layout properties.
|
|
733
|
+
*
|
|
734
|
+
* Returns a flat list with dedupe: layout combinations that appear
|
|
735
|
+
* multiple times across the file collapse to one entry — the model
|
|
736
|
+
* matches them against the screenshot visually (same way it matches a
|
|
737
|
+
* color hex or a font name).
|
|
738
|
+
*
|
|
739
|
+
* Frames with `layoutMode: NONE` are SKIPPED — autolayout-free frames
|
|
740
|
+
* use absolute positioning which the screenshot conveys anyway. Only
|
|
741
|
+
* autolayout frames give the model usable spacing/alignment values. */
|
|
742
|
+
export function extractAllLayoutValues(fileJson, sectionBBox) {
|
|
743
|
+
const out = [];
|
|
744
|
+
const seen = new Set();
|
|
745
|
+
function walk(node) {
|
|
746
|
+
if (!node)
|
|
747
|
+
return;
|
|
748
|
+
const isFrame = node.type === "FRAME" || node.type === "COMPONENT" || node.type === "INSTANCE";
|
|
749
|
+
if (isFrame && (node.layoutMode === "HORIZONTAL" || node.layoutMode === "VERTICAL") &&
|
|
750
|
+
(!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox))) {
|
|
751
|
+
const bbox = node.absoluteBoundingBox || {};
|
|
752
|
+
const entry = {
|
|
753
|
+
width: Math.round(bbox.width || 0),
|
|
754
|
+
height: Math.round(bbox.height || 0),
|
|
755
|
+
layoutMode: node.layoutMode,
|
|
756
|
+
paddingTop: Math.round(node.paddingTop || 0),
|
|
757
|
+
paddingRight: Math.round(node.paddingRight || 0),
|
|
758
|
+
paddingBottom: Math.round(node.paddingBottom || 0),
|
|
759
|
+
paddingLeft: Math.round(node.paddingLeft || 0),
|
|
760
|
+
itemSpacing: Math.round(node.itemSpacing || 0),
|
|
761
|
+
primaryAxisAlignItems: typeof node.primaryAxisAlignItems === "string" ? node.primaryAxisAlignItems : undefined,
|
|
762
|
+
counterAxisAlignItems: typeof node.counterAxisAlignItems === "string" ? node.counterAxisAlignItems : undefined,
|
|
763
|
+
};
|
|
764
|
+
// Dedupe by signature — same shape appearing multiple times collapses
|
|
765
|
+
const sig = JSON.stringify(entry);
|
|
766
|
+
if (!seen.has(sig)) {
|
|
767
|
+
seen.add(sig);
|
|
768
|
+
out.push(entry);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (Array.isArray(node.children)) {
|
|
772
|
+
for (const child of node.children)
|
|
773
|
+
walk(child);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
walk(fileJson?.document);
|
|
777
|
+
// Sort largest-first so heroes/full-page frames come up before tiny inner cards
|
|
778
|
+
return out.sort((a, b) => (b.width * b.height) - (a.width * a.height));
|
|
779
|
+
}
|
|
780
|
+
/** Walk every IMAGE-fill node — collect scaleMode hints (how the image is
|
|
781
|
+
* rendered within its frame: FILL, FIT, CROP, TILE). These are properties
|
|
782
|
+
* of the fill, not the layer structure. Helps the model decide whether
|
|
783
|
+
* to use `object-fit: cover` (FILL/CROP) vs `object-fit: contain` (FIT)
|
|
784
|
+
* vs `background-repeat` (TILE). */
|
|
785
|
+
export function extractAllImageScaleHints(fileJson, sectionBBox) {
|
|
786
|
+
const out = [];
|
|
787
|
+
const seen = new Set();
|
|
788
|
+
function walk(node) {
|
|
789
|
+
if (!node)
|
|
790
|
+
return;
|
|
791
|
+
const imageFills = (node.fills || []).filter((f) => f.type === "IMAGE" && f.visible !== false);
|
|
792
|
+
if (imageFills.length && sectionBBox && !bboxOverlaps(node.absoluteBoundingBox, sectionBBox)) {
|
|
793
|
+
// outside section — skip
|
|
794
|
+
if (Array.isArray(node.children)) {
|
|
795
|
+
for (const child of node.children)
|
|
796
|
+
walk(child);
|
|
797
|
+
}
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
for (const fill of imageFills) {
|
|
801
|
+
const bbox = node.absoluteBoundingBox || {};
|
|
802
|
+
// Heuristic: large frames (>800px wide) using image fill are likely
|
|
803
|
+
// background photos; smaller image-fill nodes are content images
|
|
804
|
+
// (avatars, card photos, icons). Pure value-based — no name reading.
|
|
805
|
+
const frameWidth = Math.round(bbox.width || 0);
|
|
806
|
+
const frameHeight = Math.round(bbox.height || 0);
|
|
807
|
+
const kind = frameWidth >= 800 ? "background-fill" : "content-image";
|
|
808
|
+
const entry = {
|
|
809
|
+
kind,
|
|
810
|
+
scaleMode: String(fill.scaleMode || "FILL"),
|
|
811
|
+
frame_width: frameWidth,
|
|
812
|
+
frame_height: frameHeight,
|
|
813
|
+
};
|
|
814
|
+
const sig = JSON.stringify(entry);
|
|
815
|
+
if (!seen.has(sig)) {
|
|
816
|
+
seen.add(sig);
|
|
817
|
+
out.push(entry);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (Array.isArray(node.children)) {
|
|
821
|
+
for (const child of node.children)
|
|
822
|
+
walk(child);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
walk(fileJson?.document);
|
|
826
|
+
return out;
|
|
827
|
+
}
|
|
828
|
+
/** Walk every node — collect vector decorations, icons, logos, shapes.
|
|
829
|
+
*
|
|
830
|
+
* Permissive heuristic: we want to catch waves, arrows, scroll indicators,
|
|
831
|
+
* logos, brand marks, divider shapes — anything Claude would otherwise
|
|
832
|
+
* invent SVG markup for. Three ways a node gets included:
|
|
833
|
+
*
|
|
834
|
+
* 1. Vector primitive with geometry (VECTOR, BOOLEAN_OPERATION, LINE,
|
|
835
|
+
* REGULAR_POLYGON, STAR, ELLIPSE with non-trivial fillGeometry/strokeGeometry)
|
|
836
|
+
* 2. Group/Frame containing only vectors (designer grouped multiple paths
|
|
837
|
+
* into one logo / icon / decoration)
|
|
838
|
+
* 3. Node name hints at decoration ("wave", "arrow", "icon", "logo",
|
|
839
|
+
* "shape", "decoration", "deco", "ornament") — even if mixed in a frame
|
|
840
|
+
*
|
|
841
|
+
* Previous version (#1 and #2 only) missed the CRBWA hero's bottom wave
|
|
842
|
+
* because it was inside the Banner frame with text content. The name-hint
|
|
843
|
+
* catch in #3 fixes that without false-positives — designer-named
|
|
844
|
+
* decorations are virtually always actual decorations. */
|
|
845
|
+
export function extractAllVectorNodes(fileJson, sectionBBox) {
|
|
846
|
+
const out = [];
|
|
847
|
+
const VECTOR_TYPES = new Set([
|
|
848
|
+
"VECTOR", "BOOLEAN_OPERATION", "LINE", "REGULAR_POLYGON",
|
|
849
|
+
"STAR", "ELLIPSE",
|
|
850
|
+
]);
|
|
851
|
+
// Recursive variant of "subtree contains only vector-like nodes" — works
|
|
852
|
+
// even when vectors are nested under arbitrary group/frame depth. Used
|
|
853
|
+
// by the structural emit rule below.
|
|
854
|
+
function subtreeOnlyVectors(node) {
|
|
855
|
+
if (!node)
|
|
856
|
+
return false;
|
|
857
|
+
const t = node.type;
|
|
858
|
+
if (VECTOR_TYPES.has(t))
|
|
859
|
+
return true;
|
|
860
|
+
if (t === "GROUP" || t === "FRAME" || t === "COMPONENT" || t === "INSTANCE") {
|
|
861
|
+
if (subtreeHasType(node, "TEXT"))
|
|
862
|
+
return false;
|
|
863
|
+
if (subtreeHasImageFill(node))
|
|
864
|
+
return false;
|
|
865
|
+
if (!Array.isArray(node.children) || node.children.length === 0)
|
|
866
|
+
return false;
|
|
867
|
+
return node.children.every((c) => subtreeOnlyVectors(c));
|
|
868
|
+
}
|
|
869
|
+
return false;
|
|
870
|
+
}
|
|
871
|
+
function shouldEmit(node) {
|
|
872
|
+
// Path 1: vector primitive with geometry
|
|
873
|
+
if (VECTOR_TYPES.has(node.type)) {
|
|
874
|
+
const hasGeometry = (Array.isArray(node.fillGeometry) && node.fillGeometry.length > 0) ||
|
|
875
|
+
(Array.isArray(node.strokeGeometry) && node.strokeGeometry.length > 0);
|
|
876
|
+
if (hasGeometry)
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
// Path 2: pure-vector container (frame/group/component/instance whose
|
|
880
|
+
// subtree at ANY depth contains only vector primitives + group wrappers,
|
|
881
|
+
// with no TEXT and no IMAGE fills). Catches decorative compositions
|
|
882
|
+
// that designers don't bother to flatten or name well. Structural
|
|
883
|
+
// only — no dependency on layer names.
|
|
884
|
+
const isContainer = node.type === "GROUP" || node.type === "FRAME"
|
|
885
|
+
|| node.type === "COMPONENT" || node.type === "INSTANCE";
|
|
886
|
+
if (isContainer && Array.isArray(node.children) && node.children.length > 0) {
|
|
887
|
+
if (subtreeOnlyVectors(node))
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
function walk(node, depth = 0) {
|
|
893
|
+
if (!node || depth > 10)
|
|
894
|
+
return;
|
|
895
|
+
if (shouldEmit(node)) {
|
|
896
|
+
// Vectors often bleed past the section's strict bbox — transition
|
|
897
|
+
// waves on the bottom edge, blobs that overhang, etc. Use an
|
|
898
|
+
// expanded check (30% of section height, min 100px) so edge
|
|
899
|
+
// decorations don't silently get dropped.
|
|
900
|
+
const expandPx = sectionBBox
|
|
901
|
+
? Math.max(100, Math.round(sectionBBox.height * 0.3))
|
|
902
|
+
: 0;
|
|
903
|
+
const overlap = !sectionBBox ||
|
|
904
|
+
bboxOverlapsExpanded(node.absoluteBoundingBox, sectionBBox, expandPx);
|
|
905
|
+
if (overlap) {
|
|
906
|
+
const bbox = node.absoluteBoundingBox || {};
|
|
907
|
+
out.push({
|
|
908
|
+
id: node.id,
|
|
909
|
+
name: node.name || "vector",
|
|
910
|
+
width: Math.round(bbox.width || 0),
|
|
911
|
+
height: Math.round(bbox.height || 0),
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return; // don't drill into emitted nodes — they render as one SVG
|
|
915
|
+
}
|
|
916
|
+
if (Array.isArray(node.children)) {
|
|
917
|
+
for (const child of node.children)
|
|
918
|
+
walk(child, depth + 1);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
walk(fileJson?.document);
|
|
922
|
+
// Dedupe by id (in case a name match catches a node we already had)
|
|
923
|
+
const seen = new Set();
|
|
924
|
+
return out.filter((n) => (seen.has(n.id) ? false : (seen.add(n.id), true)));
|
|
925
|
+
}
|
|
926
|
+
/** Find nodes whose bbox EXTENDS PAST the section's edges — these are the
|
|
927
|
+
* "bleed" elements that designers expect to overflow the section visually
|
|
928
|
+
* (cards that overlap into the next section, hero photos that hang past the
|
|
929
|
+
* bottom, decorations that breach the boundary). Without these hints,
|
|
930
|
+
* Claude renders the section as a hard rectangle and overlaps disappear.
|
|
931
|
+
*
|
|
932
|
+
* Returns one entry per overhanging node, with the direction and amount in
|
|
933
|
+
* pixels. Claude maps each entry to a CSS hint:
|
|
934
|
+
* overflow_bottom_px: 80 → apply margin-bottom:-80px (or translateY(80px))
|
|
935
|
+
* overflow_top_px: 40 → apply margin-top:-40px
|
|
936
|
+
* overflow_left_px: 100 → apply margin-left:-100px (rare)
|
|
937
|
+
* overflow_right_px: 100 → apply margin-right:-100px (rare)
|
|
938
|
+
*
|
|
939
|
+
* Only emits nodes whose CENTER is still inside the section — otherwise the
|
|
940
|
+
* node belongs to a neighbouring section. Threshold: at least 20px overhang
|
|
941
|
+
* to avoid noise from anti-aliasing / sub-pixel positioning.
|
|
942
|
+
*/
|
|
943
|
+
export function extractOverlapHints(fileJson, sectionBBox) {
|
|
944
|
+
if (!sectionBBox)
|
|
945
|
+
return [];
|
|
946
|
+
const sb = sectionBBox;
|
|
947
|
+
const out = [];
|
|
948
|
+
const sx2 = sb.x + sb.width;
|
|
949
|
+
const sy2 = sb.y + sb.height;
|
|
950
|
+
const THRESHOLD = 20;
|
|
951
|
+
function walk(node, depth = 0) {
|
|
952
|
+
if (!node || depth > 12)
|
|
953
|
+
return;
|
|
954
|
+
const bb = node.absoluteBoundingBox;
|
|
955
|
+
if (bb && bb.width > 0 && bb.height > 0) {
|
|
956
|
+
const nx2 = bb.x + bb.width;
|
|
957
|
+
const ny2 = bb.y + bb.height;
|
|
958
|
+
const cx = bb.x + bb.width / 2;
|
|
959
|
+
const cy = bb.y + bb.height / 2;
|
|
960
|
+
const centerInside = cx >= sb.x && cx <= sx2 &&
|
|
961
|
+
cy >= sb.y && cy <= sy2;
|
|
962
|
+
if (centerInside) {
|
|
963
|
+
const overTop = sb.y - bb.y;
|
|
964
|
+
const overBottom = ny2 - sy2;
|
|
965
|
+
const overLeft = sb.x - bb.x;
|
|
966
|
+
const overRight = nx2 - sx2;
|
|
967
|
+
const entry = { id: node.id, name: node.name || "node" };
|
|
968
|
+
let any = false;
|
|
969
|
+
if (overTop > THRESHOLD) {
|
|
970
|
+
entry.overflow_top_px = Math.round(overTop);
|
|
971
|
+
any = true;
|
|
972
|
+
}
|
|
973
|
+
if (overBottom > THRESHOLD) {
|
|
974
|
+
entry.overflow_bottom_px = Math.round(overBottom);
|
|
975
|
+
any = true;
|
|
976
|
+
}
|
|
977
|
+
if (overLeft > THRESHOLD) {
|
|
978
|
+
entry.overflow_left_px = Math.round(overLeft);
|
|
979
|
+
any = true;
|
|
980
|
+
}
|
|
981
|
+
if (overRight > THRESHOLD) {
|
|
982
|
+
entry.overflow_right_px = Math.round(overRight);
|
|
983
|
+
any = true;
|
|
984
|
+
}
|
|
985
|
+
if (any)
|
|
986
|
+
out.push(entry);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
if (Array.isArray(node.children)) {
|
|
990
|
+
for (const c of node.children)
|
|
991
|
+
walk(c, depth + 1);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
walk(fileJson?.document);
|
|
995
|
+
return out;
|
|
996
|
+
}
|
|
997
|
+
/** List candidate sections in the file for Claude to visually match against
|
|
998
|
+
* the user's screenshot. NO LAYER NAMES are returned — only IDs and bounding
|
|
999
|
+
* boxes. Claude compares its render (returned separately as a PNG URL by the
|
|
1000
|
+
* list_section_renders tool) to the user's screenshot and picks one.
|
|
1001
|
+
*
|
|
1002
|
+
* How sections are enumerated (value-only, no name dependency):
|
|
1003
|
+
* 1. Walk file → pages (the file root has pages — necessary minimum)
|
|
1004
|
+
* 2. For each page, walk top-level frames (the page contains frames)
|
|
1005
|
+
* 3. For each top-level frame, also include its direct children if the
|
|
1006
|
+
* frame is "tall" (>= 2500px — likely a multi-section page)
|
|
1007
|
+
*
|
|
1008
|
+
* This enumeration uses ONLY the structural minimum needed to render images
|
|
1009
|
+
* (file → page → frame → child). No layer NAMES are read. No semantic
|
|
1010
|
+
* decisions are made based on hierarchy — Claude makes all decisions
|
|
1011
|
+
* visually using the rendered PNGs. */
|
|
1012
|
+
export function listRenderableSections(fileJson) {
|
|
1013
|
+
const out = [];
|
|
1014
|
+
const pages = fileJson?.document?.children || [];
|
|
1015
|
+
for (const page of pages) {
|
|
1016
|
+
for (const topFrame of page.children || []) {
|
|
1017
|
+
if (topFrame.type !== "FRAME" &&
|
|
1018
|
+
topFrame.type !== "COMPONENT" &&
|
|
1019
|
+
topFrame.type !== "SECTION")
|
|
1020
|
+
continue;
|
|
1021
|
+
const tb = topFrame.absoluteBoundingBox;
|
|
1022
|
+
if (!tb)
|
|
1023
|
+
continue;
|
|
1024
|
+
// The whole top frame is always a candidate (e.g. single-page design)
|
|
1025
|
+
out.push({
|
|
1026
|
+
id: topFrame.id,
|
|
1027
|
+
bbox: { x: tb.x, y: tb.y, width: tb.width, height: tb.height },
|
|
1028
|
+
});
|
|
1029
|
+
// If the top frame is tall (≥2500px), it's likely a multi-section
|
|
1030
|
+
// page — also expose its direct children as candidates so Claude
|
|
1031
|
+
// can pick a specific section instead of the whole page.
|
|
1032
|
+
if (tb.height >= 2500 && Array.isArray(topFrame.children)) {
|
|
1033
|
+
for (const child of topFrame.children) {
|
|
1034
|
+
if (child.type !== "FRAME" &&
|
|
1035
|
+
child.type !== "COMPONENT" &&
|
|
1036
|
+
child.type !== "INSTANCE" &&
|
|
1037
|
+
child.type !== "SECTION" &&
|
|
1038
|
+
child.type !== "GROUP")
|
|
1039
|
+
continue;
|
|
1040
|
+
const cb = child.absoluteBoundingBox;
|
|
1041
|
+
if (!cb)
|
|
1042
|
+
continue;
|
|
1043
|
+
if (cb.width < 400 || cb.height < 200)
|
|
1044
|
+
continue; // skip tiny shapes
|
|
1045
|
+
out.push({
|
|
1046
|
+
id: child.id,
|
|
1047
|
+
bbox: { x: cb.x, y: cb.y, width: cb.width, height: cb.height },
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
return out;
|
|
1054
|
+
}
|
|
1055
|
+
/** List independently-convertable sections in a Figma file.
|
|
1056
|
+
*
|
|
1057
|
+
* For each top-level frame (a "page" in Figma): if it's TALL (>=2500px),
|
|
1058
|
+
* expose its direct child frames as separate sections; otherwise expose
|
|
1059
|
+
* the whole frame.
|
|
1060
|
+
*
|
|
1061
|
+
* This is what lets users pick ONE section at a time when their Figma
|
|
1062
|
+
* design is a full multi-section homepage. */
|
|
1063
|
+
export function listSections(fileJson) {
|
|
1064
|
+
const out = [];
|
|
1065
|
+
const pages = fileJson?.document?.children || [];
|
|
1066
|
+
for (const page of pages) {
|
|
1067
|
+
const pageBg = readSolidFill(page) ||
|
|
1068
|
+
(page.backgroundColor ? rgbToHex(page.backgroundColor) : undefined);
|
|
1069
|
+
for (const topFrame of page.children || []) {
|
|
1070
|
+
if (topFrame.type !== "FRAME" &&
|
|
1071
|
+
topFrame.type !== "COMPONENT" &&
|
|
1072
|
+
topFrame.type !== "SECTION")
|
|
1073
|
+
continue;
|
|
1074
|
+
const tb = topFrame.absoluteBoundingBox;
|
|
1075
|
+
const tw = tb ? Math.round(tb.width) : 0;
|
|
1076
|
+
const th = tb ? Math.round(tb.height) : 0;
|
|
1077
|
+
const topBg = readSolidFill(topFrame) || pageBg;
|
|
1078
|
+
// Tall page → expose inner sections
|
|
1079
|
+
if (th >= 2500 && Array.isArray(topFrame.children)) {
|
|
1080
|
+
const innerSections = [];
|
|
1081
|
+
for (const child of topFrame.children) {
|
|
1082
|
+
if (child.type !== "FRAME" &&
|
|
1083
|
+
child.type !== "COMPONENT" &&
|
|
1084
|
+
child.type !== "INSTANCE" &&
|
|
1085
|
+
child.type !== "SECTION" &&
|
|
1086
|
+
child.type !== "GROUP")
|
|
1087
|
+
continue;
|
|
1088
|
+
const cb = child.absoluteBoundingBox;
|
|
1089
|
+
const cw = cb ? Math.round(cb.width) : 0;
|
|
1090
|
+
const ch = cb ? Math.round(cb.height) : 0;
|
|
1091
|
+
if (cw < 200 || ch < 80)
|
|
1092
|
+
continue;
|
|
1093
|
+
innerSections.push({
|
|
1094
|
+
id: child.id,
|
|
1095
|
+
name: `${topFrame.name} › ${child.name}`,
|
|
1096
|
+
type: child.type,
|
|
1097
|
+
width: cw,
|
|
1098
|
+
height: ch,
|
|
1099
|
+
parentBg: readSolidFill(child) || topBg,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
if (innerSections.length > 0) {
|
|
1103
|
+
out.push(...innerSections);
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
out.push({
|
|
1108
|
+
id: topFrame.id,
|
|
1109
|
+
name: topFrame.name,
|
|
1110
|
+
type: topFrame.type,
|
|
1111
|
+
width: tw,
|
|
1112
|
+
height: th,
|
|
1113
|
+
parentBg: topBg,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
1119
|
+
/* ─── Extended-fidelity extractors ──────────────────────────────────────
|
|
1120
|
+
*
|
|
1121
|
+
* These 5 extractors close the "things that silently dropped to default"
|
|
1122
|
+
* gap. Each walks the (possibly section-scoped) tree and returns ready-
|
|
1123
|
+
* to-paste CSS values so Claude never has to invent shadows / radii /
|
|
1124
|
+
* borders / opacity / responsive intent.
|
|
1125
|
+
*
|
|
1126
|
+
* All are pure functions, all skip invisible nodes, all dedupe by their
|
|
1127
|
+
* CSS payload so a value used many times collapses to one entry.
|
|
1128
|
+
*/
|
|
1129
|
+
/** Drop shadows, inner shadows, layer blurs, background blurs — returned as
|
|
1130
|
+
* ready-to-paste CSS (box-shadow / filter / backdrop-filter). Without this,
|
|
1131
|
+
* shadowed cards rendered flat — the single biggest cause of "depth feels
|
|
1132
|
+
* missing" complaints. */
|
|
1133
|
+
export function extractAllEffects(fileJson, sectionBBox) {
|
|
1134
|
+
const out = [];
|
|
1135
|
+
function effectsToCss(effects) {
|
|
1136
|
+
if (!Array.isArray(effects))
|
|
1137
|
+
return {};
|
|
1138
|
+
const boxParts = [];
|
|
1139
|
+
let filter;
|
|
1140
|
+
let backdrop;
|
|
1141
|
+
for (const e of effects) {
|
|
1142
|
+
if (!e || e.visible === false)
|
|
1143
|
+
continue;
|
|
1144
|
+
const c = e.color || {};
|
|
1145
|
+
const hex = rgbToHex(c) || "#000000";
|
|
1146
|
+
const alpha = typeof c.a === "number" ? c.a : 1;
|
|
1147
|
+
const rgba = `rgba(${Math.round((c.r || 0) * 255)},${Math.round((c.g || 0) * 255)},${Math.round((c.b || 0) * 255)},${Number(alpha.toFixed(3))})`;
|
|
1148
|
+
void hex;
|
|
1149
|
+
if (e.type === "DROP_SHADOW" || e.type === "INNER_SHADOW") {
|
|
1150
|
+
const x = Math.round(e.offset?.x || 0);
|
|
1151
|
+
const y = Math.round(e.offset?.y || 0);
|
|
1152
|
+
const blur = Math.round(e.radius || 0);
|
|
1153
|
+
const spread = Math.round(e.spread || 0);
|
|
1154
|
+
const inset = e.type === "INNER_SHADOW" ? "inset " : "";
|
|
1155
|
+
boxParts.push(`${inset}${x}px ${y}px ${blur}px ${spread}px ${rgba}`);
|
|
1156
|
+
}
|
|
1157
|
+
else if (e.type === "LAYER_BLUR") {
|
|
1158
|
+
filter = (filter ? filter + " " : "") + `blur(${Math.round(e.radius || 0)}px)`;
|
|
1159
|
+
}
|
|
1160
|
+
else if (e.type === "BACKGROUND_BLUR") {
|
|
1161
|
+
backdrop = (backdrop ? backdrop + " " : "") + `blur(${Math.round(e.radius || 0)}px)`;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return {
|
|
1165
|
+
box: boxParts.length ? boxParts.join(", ") : undefined,
|
|
1166
|
+
filter,
|
|
1167
|
+
backdrop,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
function walk(node) {
|
|
1171
|
+
if (!node)
|
|
1172
|
+
return;
|
|
1173
|
+
if (Array.isArray(node.effects) && node.effects.length > 0 &&
|
|
1174
|
+
(!sectionBBox || bboxOverlaps(node.absoluteBoundingBox, sectionBBox))) {
|
|
1175
|
+
const { box, filter, backdrop } = effectsToCss(node.effects);
|
|
1176
|
+
if (box || filter || backdrop) {
|
|
1177
|
+
const bb = node.absoluteBoundingBox || {};
|
|
1178
|
+
out.push({
|
|
1179
|
+
box_shadow_css: box,
|
|
1180
|
+
filter_css: filter,
|
|
1181
|
+
backdrop_filter_css: backdrop,
|
|
1182
|
+
bbox: {
|
|
1183
|
+
x: Math.round(bb.x || 0),
|
|
1184
|
+
y: Math.round(bb.y || 0),
|
|
1185
|
+
width: Math.round(bb.width || 0),
|
|
1186
|
+
height: Math.round(bb.height || 0),
|
|
1187
|
+
},
|
|
1188
|
+
});
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (Array.isArray(node.children))
|
|
1192
|
+
for (const c of node.children)
|
|
1193
|
+
walk(c);
|
|
1194
|
+
}
|
|
1195
|
+
walk(fileJson?.document);
|
|
1196
|
+
return out;
|
|
1197
|
+
}
|
|
1198
|
+
/** Per-corner border radius — Figma stores `rectangleCornerRadii` as
|
|
1199
|
+
* [topLeft, topRight, bottomRight, bottomLeft] when corners differ, or a
|
|
1200
|
+
* single `cornerRadius` number when all four match. Translated to CSS
|
|
1201
|
+
* `border-radius: a b c d` (TL TR BR BL — same order Figma uses). */
|
|
1202
|
+
export function extractAllCornerRadii(fileJson, sectionBBox) {
|
|
1203
|
+
const out = [];
|
|
1204
|
+
function walk(node) {
|
|
1205
|
+
if (!node)
|
|
1206
|
+
return;
|
|
1207
|
+
const bb = node.absoluteBoundingBox;
|
|
1208
|
+
const inScope = !sectionBBox || bboxOverlaps(bb, sectionBBox);
|
|
1209
|
+
if (inScope) {
|
|
1210
|
+
let r = null;
|
|
1211
|
+
if (Array.isArray(node.rectangleCornerRadii) && node.rectangleCornerRadii.length === 4) {
|
|
1212
|
+
r = [
|
|
1213
|
+
node.rectangleCornerRadii[0],
|
|
1214
|
+
node.rectangleCornerRadii[1],
|
|
1215
|
+
node.rectangleCornerRadii[2],
|
|
1216
|
+
node.rectangleCornerRadii[3],
|
|
1217
|
+
];
|
|
1218
|
+
}
|
|
1219
|
+
else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) {
|
|
1220
|
+
r = [node.cornerRadius, node.cornerRadius, node.cornerRadius, node.cornerRadius];
|
|
1221
|
+
}
|
|
1222
|
+
if (r && bb) {
|
|
1223
|
+
const css = r.every((v) => v === r[0])
|
|
1224
|
+
? `border-radius:${Math.round(r[0])}px`
|
|
1225
|
+
: `border-radius:${Math.round(r[0])}px ${Math.round(r[1])}px ${Math.round(r[2])}px ${Math.round(r[3])}px`;
|
|
1226
|
+
out.push({
|
|
1227
|
+
css,
|
|
1228
|
+
radii: r.map((v) => Math.round(v)),
|
|
1229
|
+
bbox: {
|
|
1230
|
+
x: Math.round(bb.x), y: Math.round(bb.y),
|
|
1231
|
+
width: Math.round(bb.width), height: Math.round(bb.height),
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (Array.isArray(node.children))
|
|
1237
|
+
for (const c of node.children)
|
|
1238
|
+
walk(c);
|
|
1239
|
+
}
|
|
1240
|
+
walk(fileJson?.document);
|
|
1241
|
+
return out;
|
|
1242
|
+
}
|
|
1243
|
+
/** Strokes — border-width, color, dashed/dotted pattern, alignment.
|
|
1244
|
+
* Returned as ready-to-paste CSS `border:1px dashed #...` so Claude
|
|
1245
|
+
* doesn't have to assemble. Without this, dashed/dotted borders dropped
|
|
1246
|
+
* to solid silently. */
|
|
1247
|
+
export function extractAllStrokes(fileJson, sectionBBox) {
|
|
1248
|
+
const out = [];
|
|
1249
|
+
function walk(node) {
|
|
1250
|
+
if (!node)
|
|
1251
|
+
return;
|
|
1252
|
+
const bb = node.absoluteBoundingBox;
|
|
1253
|
+
const inScope = !sectionBBox || bboxOverlaps(bb, sectionBBox);
|
|
1254
|
+
if (inScope && Array.isArray(node.strokes) && node.strokes.length > 0 && node.strokeWeight) {
|
|
1255
|
+
const stroke = node.strokes.find((s) => s.type === "SOLID" && s.visible !== false);
|
|
1256
|
+
if (stroke && stroke.color) {
|
|
1257
|
+
const hex = rgbToHex(stroke.color) || "#000000";
|
|
1258
|
+
const width = Math.round(node.strokeWeight);
|
|
1259
|
+
// dashPattern: [dash, gap, ...] in px. Empty/undefined → solid.
|
|
1260
|
+
let style = "solid";
|
|
1261
|
+
if (Array.isArray(node.strokeDashes) && node.strokeDashes.length > 0) {
|
|
1262
|
+
const d = node.strokeDashes[0] || 0;
|
|
1263
|
+
style = d <= width * 1.2 ? "dotted" : "dashed";
|
|
1264
|
+
}
|
|
1265
|
+
if (bb) {
|
|
1266
|
+
out.push({
|
|
1267
|
+
css: `border:${width}px ${style} ${hex}`,
|
|
1268
|
+
bbox: {
|
|
1269
|
+
x: Math.round(bb.x), y: Math.round(bb.y),
|
|
1270
|
+
width: Math.round(bb.width), height: Math.round(bb.height),
|
|
1271
|
+
},
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
if (Array.isArray(node.children))
|
|
1277
|
+
for (const c of node.children)
|
|
1278
|
+
walk(c);
|
|
1279
|
+
}
|
|
1280
|
+
walk(fileJson?.document);
|
|
1281
|
+
return out;
|
|
1282
|
+
}
|
|
1283
|
+
/** Node-level opacity (< 1) on any container. Caught silently going to
|
|
1284
|
+
* 100% before. */
|
|
1285
|
+
export function extractAllOpacities(fileJson, sectionBBox) {
|
|
1286
|
+
const out = [];
|
|
1287
|
+
function walk(node) {
|
|
1288
|
+
if (!node)
|
|
1289
|
+
return;
|
|
1290
|
+
const bb = node.absoluteBoundingBox;
|
|
1291
|
+
const inScope = !sectionBBox || bboxOverlaps(bb, sectionBBox);
|
|
1292
|
+
if (inScope && typeof node.opacity === "number" && node.opacity < 1 && bb) {
|
|
1293
|
+
out.push({
|
|
1294
|
+
opacity: Number(node.opacity.toFixed(3)),
|
|
1295
|
+
bbox: {
|
|
1296
|
+
x: Math.round(bb.x), y: Math.round(bb.y),
|
|
1297
|
+
width: Math.round(bb.width), height: Math.round(bb.height),
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
if (Array.isArray(node.children))
|
|
1302
|
+
for (const c of node.children)
|
|
1303
|
+
walk(c);
|
|
1304
|
+
}
|
|
1305
|
+
walk(fileJson?.document);
|
|
1306
|
+
return out;
|
|
1307
|
+
}
|
|
1308
|
+
/** Responsive intent — Figma constraints tell us how each node should behave
|
|
1309
|
+
* when the frame resizes. Translated to flex/sizing hints so Claude generates
|
|
1310
|
+
* responsive HTML based on REAL designer intent, not guessed defaults.
|
|
1311
|
+
*
|
|
1312
|
+
* Mapping:
|
|
1313
|
+
* LEFT_RIGHT (both anchored) → width:100% / stretch
|
|
1314
|
+
* SCALE → percentage-based width
|
|
1315
|
+
* CENTER → margin:auto (or align-self center)
|
|
1316
|
+
* LEFT / RIGHT → anchored to that side
|
|
1317
|
+
* TOP_BOTTOM (vertical) → height:100%
|
|
1318
|
+
* SCALE (vertical) → percentage-based height
|
|
1319
|
+
*/
|
|
1320
|
+
export function extractAllResponsiveHints(fileJson, sectionBBox) {
|
|
1321
|
+
const out = [];
|
|
1322
|
+
const HMAP = {
|
|
1323
|
+
LEFT: "left-anchored (default)",
|
|
1324
|
+
RIGHT: "right-anchored (margin-left:auto)",
|
|
1325
|
+
CENTER: "horizontally centered (margin-inline:auto)",
|
|
1326
|
+
SCALE: "scales with parent width (use % width)",
|
|
1327
|
+
LEFT_RIGHT: "stretches with parent (width:100% / flex:1)",
|
|
1328
|
+
};
|
|
1329
|
+
const VMAP = {
|
|
1330
|
+
TOP: "top-anchored (default)",
|
|
1331
|
+
BOTTOM: "bottom-anchored (margin-top:auto)",
|
|
1332
|
+
CENTER: "vertically centered (align-self:center)",
|
|
1333
|
+
SCALE: "scales with parent height (use % height)",
|
|
1334
|
+
TOP_BOTTOM: "stretches with parent (height:100% / flex:1)",
|
|
1335
|
+
};
|
|
1336
|
+
function walk(node) {
|
|
1337
|
+
if (!node)
|
|
1338
|
+
return;
|
|
1339
|
+
const bb = node.absoluteBoundingBox;
|
|
1340
|
+
const inScope = !sectionBBox || bboxOverlaps(bb, sectionBBox);
|
|
1341
|
+
if (inScope && node.constraints && bb) {
|
|
1342
|
+
const h = HMAP[node.constraints.horizontal];
|
|
1343
|
+
const v = VMAP[node.constraints.vertical];
|
|
1344
|
+
if (h && v) {
|
|
1345
|
+
out.push({
|
|
1346
|
+
horizontal: h,
|
|
1347
|
+
vertical: v,
|
|
1348
|
+
bbox: {
|
|
1349
|
+
x: Math.round(bb.x), y: Math.round(bb.y),
|
|
1350
|
+
width: Math.round(bb.width), height: Math.round(bb.height),
|
|
1351
|
+
},
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
if (Array.isArray(node.children))
|
|
1356
|
+
for (const c of node.children)
|
|
1357
|
+
walk(c);
|
|
1358
|
+
}
|
|
1359
|
+
walk(fileJson?.document);
|
|
1360
|
+
// Dedupe identical pairings
|
|
1361
|
+
const seen = new Set();
|
|
1362
|
+
return out.filter((e) => {
|
|
1363
|
+
const k = `${e.horizontal}|${e.vertical}`;
|
|
1364
|
+
if (seen.has(k))
|
|
1365
|
+
return false;
|
|
1366
|
+
seen.add(k);
|
|
1367
|
+
return true;
|
|
1368
|
+
});
|
|
1369
|
+
}
|