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/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
+ }