/** * Raw Figma → compact transforms. This is where token savings happen. * * Rules of engagement: * - Drop fields with default values (opacity=1, no padding, no stroke, ...). * - Convert 0-1 RGB to hex/rgba strings. * - Round bboxes to ints — sub-pixel precision is noise for most prompts. * - Never emit vector path data, effect details, or render bounds. * - Respect a `maxDepth`; track `truncatedChildren` so the LLM knows what * it's missing and can ask for more with a targeted follow-up call. */ import type { CompactNode, FigmaColor, FigmaNode, FigmaPaint, FigmaStyle, FigmaComponent, TokenBundle, } from "./types.js"; // -------------------------------------------------------------------------- // // Nodes // -------------------------------------------------------------------------- // export interface CompactOptions { maxDepth: number; // 0 = just this node, no children includeHidden?: boolean; } export function compactNode(node: FigmaNode, opts: CompactOptions): CompactNode { const out: CompactNode = { id: node.id, name: node.name, type: node.type, }; if (node.absoluteBoundingBox) { const b = node.absoluteBoundingBox; out.bbox = [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]; } if (node.layoutMode === "HORIZONTAL") out.layout = "H"; else if (node.layoutMode === "VERTICAL") out.layout = "V"; const pad: [number, number, number, number] = [ node.paddingTop ?? 0, node.paddingRight ?? 0, node.paddingBottom ?? 0, node.paddingLeft ?? 0, ]; if (pad.some((p) => p > 0)) out.padding = pad; if (typeof node.itemSpacing === "number" && node.itemSpacing > 0) out.gap = node.itemSpacing; if (node.rectangleCornerRadii) { const [tl, tr, br, bl] = node.rectangleCornerRadii; if (tl === tr && tr === br && br === bl) { if (tl > 0) out.radius = tl; } else { out.radius = node.rectangleCornerRadii; } } else if (typeof node.cornerRadius === "number" && node.cornerRadius > 0) { out.radius = node.cornerRadius; } const fill = firstVisibleSolid(node.fills); if (fill) out.fill = fill; const stroke = firstVisibleSolid(node.strokes); if (stroke && typeof node.strokeWeight === "number" && node.strokeWeight > 0) { out.stroke = { color: stroke, width: node.strokeWeight }; } if (typeof node.opacity === "number" && node.opacity < 1) out.opacity = round2(node.opacity); if (node.type === "TEXT") { if (typeof node.characters === "string") out.text = node.characters; if (node.style) { const s = node.style; const textStyle: NonNullable = {}; if (s.fontFamily) textStyle.font = s.fontFamily; if (typeof s.fontSize === "number") textStyle.size = s.fontSize; if (typeof s.fontWeight === "number") textStyle.weight = s.fontWeight; if (typeof s.lineHeightPx === "number") textStyle.lineHeight = s.lineHeightPx; if (Object.keys(textStyle).length > 0) out.textStyle = textStyle; } } if (node.type === "INSTANCE" && node.componentId) { out.component = node.componentId; } if (node.children && node.children.length > 0) { if (opts.maxDepth <= 0) { out.truncatedChildren = countDescendants(node); } else { const kept: CompactNode[] = []; let dropped = 0; for (const child of node.children) { if (!opts.includeHidden && child.visible === false) { dropped++; continue; } kept.push(compactNode(child, { ...opts, maxDepth: opts.maxDepth - 1 })); } if (kept.length > 0) out.children = kept; if (dropped > 0) out.truncatedChildren = (out.truncatedChildren ?? 0) + dropped; } } return out; } // -------------------------------------------------------------------------- // // Outline: id + name + type + children, depth-bounded. Nothing visual. // Used by figma_map for the cheapest possible overview. // -------------------------------------------------------------------------- // export interface OutlineNode { id: string; name: string; type: string; children?: OutlineNode[]; truncatedChildren?: number; } export function outlineNode(node: FigmaNode, maxDepth: number): OutlineNode { const out: OutlineNode = { id: node.id, name: node.name, type: node.type }; if (node.children && node.children.length > 0) { if (maxDepth <= 0) { out.truncatedChildren = countDescendants(node); } else { out.children = node.children.map((c) => outlineNode(c, maxDepth - 1)); } } return out; } // -------------------------------------------------------------------------- // // Text-only walk — emits a flat list of TEXT nodes with breadcrumb paths. // The cheapest possible mode for content audits / copy extraction. // -------------------------------------------------------------------------- // export interface TextEntry { id: string; path: string; // "Page / Frame / Subframe / TextNodeName" text: string; } export function extractText(node: FigmaNode, pathParts: string[] = []): TextEntry[] { const here = [...pathParts, node.name]; const out: TextEntry[] = []; if (node.type === "TEXT" && typeof node.characters === "string" && node.characters.length > 0) { out.push({ id: node.id, path: here.join(" / "), text: node.characters }); } if (node.children) { for (const child of node.children) { out.push(...extractText(child, here)); } } return out; } // -------------------------------------------------------------------------- // // Styles + components into a flat TokenBundle. Styles are only *declarations* // at the top level of the file response; to get their actual values we would // need to fetch /v1/files/:key/styles and then /v1/files/:key/nodes for each. // v1 emits names only, with TODO for value resolution. // -------------------------------------------------------------------------- // export function buildTokenBundle( styles: Record | undefined, _components: Record | undefined, ): TokenBundle { const bundle: TokenBundle = { colors: [], text: [], effects: [], grids: [] }; if (!styles) return bundle; for (const s of Object.values(styles)) { switch (s.styleType) { case "FILL": bundle.colors.push({ name: s.name, value: "", description: s.description }); break; case "TEXT": bundle.text.push({ name: s.name, description: s.description }); break; case "EFFECT": bundle.effects.push({ name: s.name, description: s.description }); break; case "GRID": bundle.grids.push({ name: s.name, description: s.description }); break; } } return bundle; } // -------------------------------------------------------------------------- // // Helpers // -------------------------------------------------------------------------- // function firstVisibleSolid(paints: FigmaPaint[] | undefined): string | undefined { if (!paints) return undefined; for (const p of paints) { if (p.visible === false) continue; if (p.type === "SOLID" && p.color) return colorToString(p.color, p.opacity); // Gradients/images summarized as a tag so the LLM at least knows it exists. if (p.type.startsWith("GRADIENT_")) return `<${p.type.toLowerCase()}>`; if (p.type === "IMAGE") return ""; } return undefined; } function colorToString(color: FigmaColor, paintOpacity?: number): string { const alpha = (color.a ?? 1) * (paintOpacity ?? 1); const r = clamp255(color.r); const g = clamp255(color.g); const b = clamp255(color.b); if (alpha >= 0.999) { return `#${hex2(r)}${hex2(g)}${hex2(b)}`; } return `rgba(${r}, ${g}, ${b}, ${round2(alpha)})`; } function clamp255(v: number): number { return Math.max(0, Math.min(255, Math.round(v * 255))); } function hex2(n: number): string { return n.toString(16).padStart(2, "0"); } function round2(n: number): number { return Math.round(n * 100) / 100; } function countDescendants(node: FigmaNode): number { if (!node.children) return 0; let count = node.children.length; for (const c of node.children) count += countDescendants(c); return count; }