diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json
index 21bb8ec..3e1dee4 100644
--- a/mcp-server/package-lock.json
+++ b/mcp-server/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
diff --git a/mcp-server/package.json b/mcp-server/package.json
index ae9dcdc..ab86a9a 100644
--- a/mcp-server/package.json
+++ b/mcp-server/package.json
@@ -1,6 +1,6 @@
{
"name": "drawd-mcp-server",
- "version": "1.1.0",
+ "version": "1.1.1",
"description": "MCP server for Drawd — AI agent flow builder. Create app flow designs programmatically with AI agents.",
"type": "module",
"bin": {
diff --git a/src/utils/figmaToHtml.js b/src/utils/figmaToHtml.js
index bb6bccd..ca730df 100644
--- a/src/utils/figmaToHtml.js
+++ b/src/utils/figmaToHtml.js
@@ -311,33 +311,46 @@ function vectorNetworkToSvgPath(network) {
const { vertices, segments } = network;
if (!segments?.length || !vertices?.length) return "";
+ // Detect format: kiwi uses {x, y} objects; REST API uses [x, y] arrays.
+ const isKiwi = typeof vertices[0]?.x === "number";
+
+ const vx = (v) => (isKiwi ? v.x : v[0]);
+ const vy = (v) => (isKiwi ? v.y : v[1]);
+
const parts = [];
let currentStart = null;
let previousEnd = null;
for (const seg of segments) {
- const { a, b, ta, tb } = seg;
+ // Kiwi: { start: { vertex, dx, dy }, end: { vertex, dx, dy } }
+ // REST: { a, b, ta: [dx, dy], tb: [dx, dy] }
+ const a = isKiwi ? seg.start.vertex : seg.a;
+ const b = isKiwi ? seg.end.vertex : seg.b;
+ const ta0 = isKiwi ? (seg.start.dx ?? 0) : (seg.ta?.[0] ?? 0);
+ const ta1 = isKiwi ? (seg.start.dy ?? 0) : (seg.ta?.[1] ?? 0);
+ const tb0 = isKiwi ? (seg.end.dx ?? 0) : (seg.tb?.[0] ?? 0);
+ const tb1 = isKiwi ? (seg.end.dy ?? 0) : (seg.tb?.[1] ?? 0);
+
const start = vertices[a];
const end = vertices[b];
if (!start || !end) continue;
if (previousEnd !== a) {
- parts.push(`M${fmt(start[0])} ${fmt(start[1])}`);
+ parts.push(`M${fmt(vx(start))} ${fmt(vy(start))}`);
currentStart = a;
}
- const noTangents =
- (ta[0] === 0 && ta[1] === 0 && tb[0] === 0 && tb[1] === 0);
+ const noTangents = (ta0 === 0 && ta1 === 0 && tb0 === 0 && tb1 === 0);
if (noTangents) {
- parts.push(`L${fmt(end[0])} ${fmt(end[1])}`);
+ parts.push(`L${fmt(vx(end))} ${fmt(vy(end))}`);
} else {
- const c1x = start[0] + ta[0];
- const c1y = start[1] + ta[1];
- const c2x = end[0] + tb[0];
- const c2y = end[1] + tb[1];
+ const c1x = vx(start) + ta0;
+ const c1y = vy(start) + ta1;
+ const c2x = vx(end) + tb0;
+ const c2y = vy(end) + tb1;
parts.push(
- `C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end[0])} ${fmt(end[1])}`
+ `C${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(vx(end))} ${fmt(vy(end))}`
);
}
@@ -381,8 +394,21 @@ function convertTextNode(node, isRoot) {
const { width, height } = getNodeSize(node);
const styles = {};
+ // textAutoResize controls Figma's text sizing behaviour:
+ // WIDTH_AND_HEIGHT — auto-size both axes (single-line, never wraps)
+ // HEIGHT — fixed width, auto-height (wraps at width)
+ // NONE / TRUNCATE — fully fixed size
+ const autoResize = node.textAutoResize;
+
if (!isRoot) {
- styles.width = `${Math.ceil(width)}px`;
+ if (autoResize === "WIDTH_AND_HEIGHT") {
+ // Auto-width text: don't constrain width, prevent wrapping
+ styles.whiteSpace = "nowrap";
+ styles.flexShrink = "0";
+ } else {
+ // Fixed-width text: add 1px buffer for font metric differences
+ styles.width = `${Math.ceil(width) + 1}px`;
+ }
styles.minHeight = `${Math.ceil(height)}px`;
}
@@ -548,6 +574,13 @@ function convertFrameNode(node, isRoot) {
styles.flexGrow = node.stackChildPrimaryGrow;
}
+ // Library component instances with no resolved children may carry a
+ // pre-built SVG from derivedSymbolData rendering hints.
+ if (node._derivedSvg && (!node.children || node.children.length === 0)) {
+ const inlineStyle = stylesToString(styles);
+ return `
\n${node._derivedSvg}\n
`;
+ }
+
// Render children
const childrenHtml = (node.children || [])
.map((child, i) => {
@@ -591,16 +624,21 @@ function convertShapeNode(node) {
}
// Fill color
+ // Figma explicitly sets fills=[] when a node has no fill (stroke-only icons).
+ // Use "none" for empty arrays; only fall back to "currentColor" when fills
+ // is undefined (possible inherited fill from boolean operation parent).
const fills = node.fillPaints ?? node.fills;
- let fillColor = "currentColor";
+ let fillColor = "none";
if (fills?.length) {
const solidFill = fills.find((f) => f.type === "SOLID" && f.visible !== false);
if (solidFill) {
fillColor = figmaColorToCss(solidFill.color, solidFill.opacity);
}
+ } else if (fills == null) {
+ fillColor = "currentColor";
}
- // Stroke color
+ // Stroke color + linecap/linejoin
const strokes = node.strokePaints ?? node.strokes;
const strokeWeight = node.strokeWeight ?? 0;
let strokeAttr = "";
@@ -609,11 +647,22 @@ function convertShapeNode(node) {
if (solidStroke) {
const strokeColor = figmaColorToCss(solidStroke.color, solidStroke.opacity);
strokeAttr = ` stroke="${strokeColor}" stroke-width="${strokeWeight}"`;
+ // Stroke line cap and join
+ const cap = node.strokeCap;
+ if (cap === "ROUND") strokeAttr += ` stroke-linecap="round"`;
+ else if (cap === "SQUARE") strokeAttr += ` stroke-linecap="square"`;
+ const join = node.strokeJoin;
+ if (join === "ROUND") strokeAttr += ` stroke-linejoin="round"`;
+ else if (join === "BEVEL") strokeAttr += ` stroke-linejoin="bevel"`;
}
}
+ // Use fill-rule from vectorNetwork regions if available
+ const windingRule = node.vectorNetwork?.regions?.[0]?.windingRule;
+ const fillRuleAttr = windingRule === "EVENODD" ? ` fill-rule="evenodd"` : "";
+
const wrapStyle = stylesToString(styles);
- return ``;
+ return ``;
}
// Fallback: render as a colored div (no vector path data available)
diff --git a/src/utils/parseFigmaClipboard.js b/src/utils/parseFigmaClipboard.js
index 5d3c0c3..9523b2f 100644
--- a/src/utils/parseFigmaClipboard.js
+++ b/src/utils/parseFigmaClipboard.js
@@ -32,6 +32,23 @@ const KIWI_LAYOUT_PROPS = [
"stackPrimarySizing",
];
+// Stroke & vector properties from kiwi that the factory drops.
+// Needed for proper stroke-based icon rendering in figmaToHtml.js.
+const KIWI_STROKE_PROPS = [
+ "strokePaints",
+ "strokeWeight",
+ "strokeCap",
+ "strokeJoin",
+ "strokeAlign",
+ "fillPaints",
+];
+
+// Text properties from kiwi that the factory drops.
+// Needed for correct text sizing/wrapping in figmaToHtml.js.
+const KIWI_TEXT_PROPS = [
+ "textAutoResize",
+];
+
iofigma.kiwi.factory.node = function (nc, message) {
if (captureState) {
captureState.message = message;
@@ -49,6 +66,25 @@ iofigma.kiwi.factory.node = function (nc, message) {
node[prop] = nc[prop];
}
}
+ // Preserve stroke properties for vector/icon rendering
+ for (const prop of KIWI_STROKE_PROPS) {
+ if (nc[prop] != null && node[prop] == null) {
+ node[prop] = nc[prop];
+ }
+ }
+ // Preserve text properties for correct sizing/wrapping
+ for (const prop of KIWI_TEXT_PROPS) {
+ if (nc[prop] != null && node[prop] == null) {
+ node[prop] = nc[prop];
+ }
+ }
+ // Preserve kiwi parentIndex.position for correct child re-sorting.
+ // The refig library sorts with localeCompare which produces wrong order
+ // for Figma's mixed-case ASCII fractional indices; resortFigFileChildren
+ // uses this value to re-sort with byte-level comparison.
+ if (nc.parentIndex?.position != null) {
+ node._kiwiParentPosition = nc.parentIndex.position;
+ }
}
return node;
@@ -64,6 +100,43 @@ function endCapture() {
return result;
}
+// Figma's fractional indices (parentIndex.position) use mixed-case ASCII
+// characters designed for byte-level comparison. The refig library sorts
+// children with localeCompare, which is locale-aware and case-insensitive,
+// producing wrong order (e.g. "i" < "P" alphabetically, but "P" < "i" in
+// ASCII). This comparator uses plain string comparison to match Figma's
+// intended ordering.
+function comparePosition(a, b) {
+ if (a < b) return -1;
+ if (a > b) return 1;
+ return 0;
+}
+
+// Re-sort all children in the _figFile tree using byte-level comparison
+// to fix the ordering produced by refig's localeCompare-based sort.
+function resortFigFileChildren(figFile) {
+ if (!figFile?.pages) return;
+ for (const page of figFile.pages) {
+ if (page.rootNodes) {
+ for (const node of page.rootNodes) {
+ resortNodeChildren(node);
+ }
+ }
+ }
+}
+
+function resortNodeChildren(node) {
+ if (!node?.children?.length) return;
+ node.children.sort((a, b) => {
+ const aPos = a._kiwiParentPosition ?? "";
+ const bPos = b._kiwiParentPosition ?? "";
+ return comparePosition(aPos, bPos);
+ });
+ for (const child of node.children) {
+ resortNodeChildren(child);
+ }
+}
+
// ---------------------------------------------------------------------------
// Shared-component resolution: process captured derivedSymbolData, build
// parent-child trees from the derived NodeChanges, and patch INSTANCE nodes
@@ -123,13 +196,14 @@ function buildDerivedTree(derivedNCs, message, parentGuid) {
}
});
- // Sort children by fractional position index
+ // Sort children by fractional position index (byte-level comparison, not
+ // localeCompare — Figma's fractional indices use mixed-case ASCII).
guidToNode.forEach((parent) => {
if (!parent.children?.length) return;
parent.children.sort((a, b) => {
const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
- return aPos.localeCompare(bPos);
+ return comparePosition(aPos, bPos);
});
});
@@ -140,6 +214,13 @@ function buildDerivedTree(derivedNCs, message, parentGuid) {
return iofigma.kiwi.guid(kiwi.parentIndex.guid) === parentGuid;
});
+ // Sort root children by fractional position index.
+ rootChildren.sort((a, b) => {
+ const aPos = guidToKiwi.get(a.id)?.parentIndex?.position ?? "";
+ const bPos = guidToKiwi.get(b.id)?.parentIndex?.position ?? "";
+ return comparePosition(aPos, bPos);
+ });
+
return { rootChildren, guidToNode, guidToKiwi };
}
@@ -203,6 +284,479 @@ function resolveNestedInstances(nodes, guidToKiwi, message, depth = 0) {
}
}
+// ---------------------------------------------------------------------------
+// Derived rendering: decode fill/stroke geometry blobs and glyph outlines
+// from library component instances whose derivedSymbolData contains rendering
+// hints (guidPath + fillGeometry/derivedTextData) instead of full nodeChanges.
+// ---------------------------------------------------------------------------
+
+/**
+ * Decode a Figma vector commands blob into an SVG path `d` string.
+ *
+ * Binary format: sequence of (command_byte, float32-LE params).
+ * 1 = MoveTo (x, y) — 2 floats
+ * 2 = LineTo (x, y) — 2 floats
+ * 4 = CubicBezier (c1x, c1y, c2x, c2y, ex, ey) — 6 floats
+ * Close is encoded as MoveTo with a sentinel x (|x| > 1e15).
+ *
+ * Glyph outline blobs start with a 0x00 header byte before the first command.
+ * Fill/stroke geometry blobs start directly with a command byte.
+ */
+function decodeFigmaCommandsBlob(blobBytes) {
+ if (!blobBytes?.length) return "";
+ const view = new DataView(blobBytes.buffer, blobBytes.byteOffset, blobBytes.byteLength);
+ let offset = 0;
+ let pathD = "";
+
+ // Glyph blobs start with 0x00 header; skip it
+ if (blobBytes[0] === 0x00 && blobBytes.length > 1 && [1, 2, 4].includes(blobBytes[1])) {
+ offset = 1;
+ }
+
+ while (offset < blobBytes.length) {
+ const cmd = blobBytes[offset];
+ offset++;
+
+ if (cmd === 1) {
+ if (offset + 8 > blobBytes.length) break;
+ const x = view.getFloat32(offset, true); offset += 4;
+ const y = view.getFloat32(offset, true); offset += 4;
+ if (Math.abs(x) > 1e15) { pathD += "Z "; continue; }
+ pathD += `M${fmt(x)} ${fmt(y)} `;
+ } else if (cmd === 2) {
+ if (offset + 8 > blobBytes.length) break;
+ const x = view.getFloat32(offset, true); offset += 4;
+ const y = view.getFloat32(offset, true); offset += 4;
+ pathD += `L${fmt(x)} ${fmt(y)} `;
+ } else if (cmd === 4) {
+ if (offset + 24 > blobBytes.length) break;
+ const c = [];
+ for (let i = 0; i < 6; i++) { c.push(view.getFloat32(offset, true)); offset += 4; }
+ pathD += `C${c.map(fmt).join(" ")} `;
+ } else {
+ break;
+ }
+ }
+ return pathD.trim();
+}
+
+/** Round to 2 decimal places for compact SVG output. */
+function fmt(n) {
+ return Math.round(n * 100) / 100;
+}
+
+/**
+ * Compute the bounding box of an SVG path `d` string by extracting all
+ * numeric coordinate pairs.
+ */
+function pathBoundingBox(pathD) {
+ const nums = pathD.match(/[-+]?\d*\.?\d+/g)?.map(Number) || [];
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
+ for (let i = 0; i < nums.length - 1; i += 2) {
+ const x = nums[i], y = nums[i + 1];
+ if (x < minX) minX = x;
+ if (x > maxX) maxX = x;
+ if (y < minY) minY = y;
+ if (y > maxY) maxY = y;
+ }
+ if (!isFinite(minX)) return { x: 0, y: 0, w: 0, h: 0 };
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
+}
+
+/**
+ * Check if derivedSymbolData entries are rendering hints (guidPath format)
+ * rather than full nodeChange objects (guid + type format).
+ */
+function isDerivedRenderingHints(derivedNCs) {
+ if (!derivedNCs?.length) return false;
+ // Rendering hints have guidPath and either fillGeometry or derivedTextData.
+ // Full nodeChanges have guid and type.
+ return derivedNCs[0].guidPath && !derivedNCs[0].guid;
+}
+
+// ---------------------------------------------------------------------------
+// iOS Status Bar: detect and render a clean HTML status bar for known system
+// components. Produces much higher fidelity than the generic SVG path fallback.
+// ---------------------------------------------------------------------------
+
+/** Standard iOS signal bars icon (4 bars ascending). */
+const IOS_SIGNAL_SVG = ``;
+
+/** Standard iOS WiFi icon. */
+const IOS_WIFI_SVG = ``;
+
+/** Standard iOS battery icon. */
+const IOS_BATTERY_SVG = ``;
+
+/**
+ * Detect whether a library component INSTANCE is an iOS status bar.
+ * Checks component name, instance dimensions, and text content.
+ */
+function isIOSStatusBar(kiwiInstance, derivedNCs) {
+ const name = (kiwiInstance.name || "").toLowerCase();
+ const w = kiwiInstance.size?.x ?? 0;
+ const h = kiwiInstance.size?.y ?? 0;
+
+ // Height check: iOS status bars are 44-54px tall
+ if (h < 40 || h > 60) return false;
+
+ // Width check: at least 300px (iPhone width range)
+ if (w < 300) return false;
+
+ // Name heuristic: common iOS status bar component names
+ const nameHints = ["status", "top bar", "navigator", "statusbar", "status bar"];
+ const nameMatch = nameHints.some((hint) => name.includes(hint));
+
+ // Text content: look for a time pattern (H:MM or HH:MM)
+ let hasTimeText = false;
+ for (const entry of derivedNCs) {
+ if (entry.derivedTextData?.glyphs?.length >= 3 && entry.derivedTextData.glyphs.length <= 5) {
+ // 3-5 glyphs matches patterns like "9:41" or "12:00"
+ hasTimeText = true;
+ }
+ }
+
+ // Need at least name match OR time text, plus fill geometry for icons
+ const hasFillGeometry = derivedNCs.some((e) => e.fillGeometry?.length > 0);
+ return (nameMatch || hasTimeText) && hasFillGeometry;
+}
+
+/**
+ * Render a clean HTML iOS status bar using the text and colors extracted
+ * from derivedSymbolData.
+ */
+function buildIOSStatusBarHtml(kiwiInstance, derivedNCs, _message) {
+ const instW = kiwiInstance.size?.x ?? 393;
+ const instH = kiwiInstance.size?.y ?? 44;
+
+ // Extract time text from derivedTextData glyphs
+ let timeText = "9:41";
+ for (const entry of derivedNCs) {
+ if (!entry.derivedTextData?.glyphs?.length) continue;
+ // Glyphs have firstCharacter index — reconstruct text
+ // The characters aren't stored directly; we infer from glyph count and layout.
+ // Common times: "9:41" (4 glyphs), "12:00" (5 glyphs)
+ const glyphCount = entry.derivedTextData.glyphs.length;
+ if (glyphCount >= 3 && glyphCount <= 5) {
+ // Use glyph count to guess format; the actual characters come from
+ // the component's text content which we can't read. Default to "9:41".
+ timeText = glyphCount === 5 ? "12:00" : "9:41";
+ break;
+ }
+ }
+
+ // Extract foreground color from symbolOverrides
+ let fgColor = "#000";
+ let bgColor = "#fff";
+ const derivedGpKeys = new Set();
+ for (const entry of derivedNCs) {
+ if (entry.guidPath?.guids) {
+ derivedGpKeys.add(entry.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/"));
+ }
+ }
+
+ for (const ov of kiwiInstance.symbolData?.symbolOverrides || []) {
+ if (!ov.guidPath?.guids?.length) continue;
+ const key = ov.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/");
+ if (ov.fillPaints?.[0]?.color) {
+ const c = ov.fillPaints[0].color;
+ const rgb = figmaColorToRgb(c);
+ if (derivedGpKeys.has(key)) {
+ fgColor = rgb; // leaf element color → foreground
+ } else {
+ bgColor = rgb; // container color → background
+ }
+ }
+ }
+
+ return `` +
+ `
${timeText}
` +
+ `
` +
+ IOS_SIGNAL_SVG + IOS_WIFI_SVG + IOS_BATTERY_SVG +
+ `
`;
+}
+
+/**
+ * Build an SVG string for a library component instance using its
+ * derivedSymbolData rendering hints (fillGeometry, strokeGeometry,
+ * derivedTextData) and symbolOverride colors.
+ *
+ * The instance's internal layout (child positions) lives in the component
+ * master on Figma's servers and is not in the clipboard. We reconstruct
+ * approximate positions by grouping elements and distributing them across
+ * the instance width.
+ *
+ * @param {object} kiwiInstance - raw kiwi nodeChange for the INSTANCE
+ * @param {object} message - kiwi message object (has .blobs)
+ * @returns {string|null} SVG or HTML markup, or null if nothing to render
+ */
+function buildDerivedInstanceSvg(kiwiInstance, message) {
+ const derivedNCs = kiwiInstance.derivedSymbolData;
+ const instW = kiwiInstance.size?.x ?? 0;
+ const instH = kiwiInstance.size?.y ?? 0;
+ if (instW < 1 || instH < 1) return null;
+
+ // Collect guidPath keys from derivedSymbolData entries (leaf elements).
+ const derivedGpKeys = new Set();
+ for (const entry of derivedNCs) {
+ if (entry.guidPath?.guids) {
+ derivedGpKeys.add(entry.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/"));
+ }
+ }
+
+ // Build color override map: guidPath key → { fill, stroke }
+ // Also detect container background fills: overrides whose guidPath doesn't
+ // match any derivedSymbolData entry belong to intermediate container frames
+ // (e.g. the white background behind a dark-themed status bar).
+ const colorMap = new Map();
+ let containerBg = null;
+ for (const ov of kiwiInstance.symbolData?.symbolOverrides || []) {
+ if (!ov.guidPath?.guids?.length) continue;
+ const key = ov.guidPath.guids.map((g) => `${g.sessionID}:${g.localID}`).join("/");
+ const entry = colorMap.get(key) || {};
+ if (ov.fillPaints?.[0]?.color) entry.fill = figmaColorToRgb(ov.fillPaints[0].color);
+ if (ov.strokePaints?.[0]?.color) entry.stroke = figmaColorToRgb(ov.strokePaints[0].color);
+ colorMap.set(key, entry);
+
+ // Container background: fill override for a non-leaf node
+ if (!derivedGpKeys.has(key) && entry.fill) {
+ containerBg = entry.fill;
+ }
+ }
+
+ // Collect all visual elements
+ const elements = []; // { type: "shape"|"glyph", svgPath, bbox, gpKey, strokePath? }
+
+ for (const entry of derivedNCs) {
+ const gpKey = entry.guidPath?.guids?.map((g) => `${g.sessionID}:${g.localID}`).join("/") || "";
+
+ if (entry.derivedTextData) {
+ // Text glyphs: each glyph is an SVG path in em-relative coordinates,
+ // positioned by glyph.position and scaled by glyph.fontSize.
+ const td = entry.derivedTextData;
+ for (const glyph of td.glyphs || []) {
+ const blob = message.blobs?.[glyph.commandsBlob]?.bytes;
+ if (!blob) continue;
+ const rawPath = decodeFigmaCommandsBlob(blob);
+ if (!rawPath) continue;
+ const fontSize = glyph.fontSize || 16;
+ elements.push({
+ type: "glyph",
+ svgPath: rawPath,
+ fontSize,
+ glyphX: glyph.position?.x ?? 0,
+ glyphY: glyph.position?.y ?? 0,
+ bbox: pathBoundingBox(rawPath),
+ gpKey,
+ });
+ }
+ }
+
+ if (entry.fillGeometry) {
+ for (const fg of entry.fillGeometry) {
+ const blob = message.blobs?.[fg.commandsBlob]?.bytes;
+ if (!blob) continue;
+ const svgPath = decodeFigmaCommandsBlob(blob);
+ if (!svgPath) continue;
+ const bbox = pathBoundingBox(svgPath);
+ if (bbox.w < 0.5 && bbox.h < 0.5) continue;
+ // Check for matching stroke
+ let strokePath = null;
+ if (entry.strokeGeometry) {
+ for (const sg of entry.strokeGeometry) {
+ const sBlob = message.blobs?.[sg.commandsBlob]?.bytes;
+ if (sBlob) strokePath = decodeFigmaCommandsBlob(sBlob);
+ }
+ }
+ elements.push({ type: "shape", svgPath, bbox, gpKey, strokePath });
+ }
+ }
+ }
+
+ if (elements.length === 0) return null;
+
+ // Group consecutive elements by guidPath prefix similarity and bbox size.
+ // Elements from the same visual group (e.g. signal bars) tend to be
+ // consecutive in derivedSymbolData and have similar bounding box dimensions.
+ const groups = [];
+ let currentGroup = [];
+ let lastBboxArea = -1;
+
+ for (const el of elements) {
+ const area = el.bbox.w * el.bbox.h;
+ const isGlyph = el.type === "glyph";
+ const prevIsGlyph = currentGroup.length > 0 && currentGroup[0].type === "glyph";
+
+ // Start a new group when element type changes, or shape sizes diverge significantly
+ if (currentGroup.length > 0) {
+ const sameType = isGlyph === prevIsGlyph;
+ const sizeRatio = lastBboxArea > 0 ? area / lastBboxArea : 1;
+ const similarSize = sizeRatio > 0.2 && sizeRatio < 5;
+
+ if (!sameType || (!isGlyph && !similarSize)) {
+ groups.push(currentGroup);
+ currentGroup = [];
+ }
+ }
+ currentGroup.push(el);
+ lastBboxArea = area;
+ }
+ if (currentGroup.length > 0) groups.push(currentGroup);
+
+ // Compute each group's composite bounding box and arrange within the instance.
+ // Heuristic: distribute groups evenly across the instance width, centered vertically.
+ const groupMetas = groups.map((group) => {
+ let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
+ let totalW = 0;
+
+ if (group[0].type === "glyph") {
+ // Glyphs are positioned relative to the text node's origin
+ for (const el of group) {
+ const scaledW = el.bbox.w * el.fontSize;
+ const scaledH = el.bbox.h * el.fontSize;
+ const x = el.glyphX;
+ const y = el.glyphY - scaledH;
+ gMinX = Math.min(gMinX, x);
+ gMinY = Math.min(gMinY, y);
+ gMaxX = Math.max(gMaxX, x + scaledW);
+ gMaxY = Math.max(gMaxY, y + scaledH);
+ }
+ } else {
+ // Shapes: stack side by side with a small gap
+ const GAP = 2;
+ let cx = 0;
+ for (const el of group) {
+ gMinY = Math.min(gMinY, el.bbox.y);
+ gMaxY = Math.max(gMaxY, el.bbox.y + el.bbox.h);
+ cx += el.bbox.w + GAP;
+ }
+ totalW = Math.max(0, cx - GAP);
+ gMinX = 0;
+ gMaxX = totalW;
+ if (!isFinite(gMinY)) { gMinY = 0; gMaxY = 0; }
+ }
+
+ return {
+ group,
+ w: gMaxX - gMinX,
+ h: gMaxY - gMinY,
+ isText: group[0].type === "glyph",
+ };
+ });
+
+ // Position groups: text groups get centered, shape groups distributed left/right.
+ const textGroups = groupMetas.filter((g) => g.isText);
+ const shapeGroups = groupMetas.filter((g) => !g.isText);
+
+ // Split shape groups into left and right by cumulative width.
+ // Walk from the start, accumulating width. Once we exceed half the
+ // available space (instance width minus text and padding), switch to right.
+ const totalShapeW = shapeGroups.reduce((s, g) => s + g.w, 0);
+ const halfTarget = totalShapeW / 2;
+
+ const leftShapes = [];
+ const rightShapes = [];
+ let accW = 0;
+ for (const g of shapeGroups) {
+ if (accW + g.w / 2 < halfTarget) {
+ leftShapes.push(g);
+ } else {
+ rightShapes.push(g);
+ }
+ accW += g.w;
+ }
+
+ // Build SVG content
+ let svgContent = "";
+
+ // Container background from symbolOverrides (intermediate frame fills)
+ if (containerBg) {
+ svgContent += ``;
+ }
+
+ const PADDING = 8;
+ const vCenter = instH / 2;
+
+ // Render left-side shape groups
+ let xCursor = PADDING;
+ for (const meta of leftShapes) {
+ svgContent += renderShapeGroup(meta, xCursor, vCenter, colorMap);
+ xCursor += meta.w + PADDING;
+ }
+
+ // Render centered text groups
+ for (const meta of textGroups) {
+ const tx = (instW - meta.w) / 2;
+ svgContent += renderGlyphGroup(meta, tx, vCenter, colorMap);
+ }
+
+ // Render right-side shape groups
+ xCursor = instW - PADDING;
+ for (let i = rightShapes.length - 1; i >= 0; i--) {
+ const meta = rightShapes[i];
+ xCursor -= meta.w;
+ svgContent += renderShapeGroup(meta, xCursor, vCenter, colorMap);
+ xCursor -= PADDING;
+ }
+
+ return ``;
+}
+
+function renderGlyphGroup(meta, groupX, vCenter, colorMap) {
+ const { group, h } = meta;
+ let svg = "";
+ const yOffset = vCenter - h / 2;
+ const gpKey = group[0]?.gpKey || "";
+ const fill = colorMap.get(gpKey)?.fill || "#333";
+
+ for (const el of group) {
+ const scale = el.fontSize;
+ // Glyph Y in Figma is the baseline position. In SVG, we flip Y since
+ // glyph paths use Y-up coordinates (0 at baseline, positive up).
+ const tx = groupX + el.glyphX;
+ const ty = yOffset + el.glyphY;
+ svg += ``;
+ svg += ``;
+ svg += "";
+ }
+ return svg;
+}
+
+function renderShapeGroup(meta, groupX, vCenter, colorMap) {
+ const { group, h } = meta;
+ let svg = "";
+ const yOffset = vCenter - h / 2;
+ const GAP = 2;
+ let cx = groupX;
+
+ for (const el of group) {
+ const gpKey = el.gpKey || "";
+ const fill = colorMap.get(gpKey)?.fill || "#333";
+ const strokeColor = colorMap.get(gpKey)?.stroke;
+ const tx = cx - el.bbox.x;
+ const ty = yOffset + (h - el.bbox.h) - el.bbox.y;
+
+ svg += ``;
+ svg += ``;
+ if (el.strokePath && strokeColor) {
+ svg += ``;
+ }
+ svg += "";
+ cx += el.bbox.w + GAP;
+ }
+ return svg;
+}
+
+function figmaColorToRgb(color) {
+ if (!color) return null;
+ const r = Math.round((color.r ?? 0) * 255);
+ const g = Math.round((color.g ?? 0) * 255);
+ const b = Math.round((color.b ?? 0) * 255);
+ const a = color.a ?? 1;
+ if (a >= 1) return `rgb(${r},${g},${b})`;
+ return `rgba(${r},${g},${b},${a.toFixed(3)})`;
+}
+
function resolveSharedComponents(doc, captured) {
if (!captured?.derived?.size) return 0;
@@ -214,7 +768,32 @@ function resolveSharedComponents(doc, captured) {
const { rootChildren, guidToKiwi } =
buildDerivedTree(derivedNCs, message, instanceGuid);
- if (rootChildren.length === 0) continue;
+
+ if (rootChildren.length === 0) {
+ // Full node tree not available — this is a library component whose
+ // derivedSymbolData contains rendering hints (guidPath + fillGeometry /
+ // derivedTextData) instead of complete nodeChange objects.
+ // Build an approximate SVG rendering from the available geometry.
+ if (isDerivedRenderingHints(derivedNCs)) {
+ const instanceNode = findNodeInFigFile(doc._figFile, instanceGuid);
+ if (instanceNode) {
+ // Try clean HTML rendering for known system components first,
+ // fall back to generic SVG path reconstruction.
+ let content = null;
+ if (isIOSStatusBar(kiwiInstance, derivedNCs)) {
+ content = buildIOSStatusBarHtml(kiwiInstance, derivedNCs, message);
+ }
+ if (!content) {
+ content = buildDerivedInstanceSvg(kiwiInstance, message);
+ }
+ if (content) {
+ instanceNode._derivedSvg = content;
+ patchCount++;
+ }
+ }
+ }
+ continue;
+ }
// Apply text / visibility overrides from the instance
applySymbolOverrides(rootChildren, kiwiInstance);
@@ -332,6 +911,11 @@ export function parseFigmaFrames(buffer) {
}
}
+ // Re-sort all children using byte-level comparison to fix refig's
+ // localeCompare-based sort which produces wrong order for Figma's
+ // mixed-case ASCII fractional indices.
+ resortFigFileChildren(doc._figFile);
+
const figFile = doc._figFile;
const frames = [];