diff --git a/mcp-server/src/figma-export/dom-traversal.js b/mcp-server/src/figma-export/dom-traversal.js index ec8b5df..53d5ac7 100644 --- a/mcp-server/src/figma-export/dom-traversal.js +++ b/mcp-server/src/figma-export/dom-traversal.js @@ -171,6 +171,28 @@ export function domTraversalFn(viewportWidth, viewportHeight) { return 'LEFT'; } + // ─── Font family sanitizer ──────────────────────────────────────────────── + // CSS system keywords and generic families are not valid Figma font names. + // Forwarding them results in blank text when pasted into Figma, so we + // resolve the first real family name or fall back to Inter. + + const FONT_KEYWORDS = new Set([ + '-apple-system', 'blinkmacsystemfont', 'system-ui', '-webkit-system-font', + 'ui-sans-serif', 'ui-serif', 'ui-monospace', 'ui-rounded', + 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', + ]); + + function sanitizeFontFamily(cssFontFamily) { + if (!cssFontFamily) return 'Inter'; + const parts = cssFontFamily.split(',').map((s) => s.replace(/['"]/g, '').trim()); + for (const p of parts) { + if (!p) continue; + if (FONT_KEYWORDS.has(p.toLowerCase())) continue; + return p; + } + return 'Inter'; + } + // ─── Build a TEXT node for a pure-text element ──────────────────────────── function makeTextNode(el, rect, cs, parentLeft, parentTop) { @@ -200,7 +222,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) { opacity: parseFloat(cs.opacity), characters: text, style: { - fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(), + fontFamily: sanitizeFontFamily(cs.fontFamily), fontPostScriptName: null, fontSize: fs, fontWeight: parseInt(cs.fontWeight) || 400, @@ -291,6 +313,49 @@ export function domTraversalFn(viewportWidth, viewportHeight) { const cs = window.getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return null; + // SVG elements are painted via SVG `fill` / `stroke` attributes, which the + // generic container path ignores (it only reads CSS `background` / `border`). + // Walking in would emit empty FRAME nodes, so collapse each into a + // single colored placeholder rectangle at its bounding box. + if (tag === 'svg') { + function resolveSvgColor(raw) { + if (!raw || raw === 'none' || raw === 'transparent') return null; + if (raw === 'currentColor') return parseRgba(cs.color); + return parseRgba(raw); + } + const iconColor = + resolveSvgColor(el.getAttribute('stroke')) || + resolveSvgColor(el.getAttribute('fill')) || + resolveSvgColor(cs.stroke) || + resolveSvgColor(cs.fill) || + parseRgba(cs.color) || + { r: 0.6, g: 0.6, b: 0.6, a: 1 }; + + const hasCircleChild = !!el.querySelector('circle'); + const isSquare = Math.abs(rect.width - rect.height) < 1; + const cornerRadius = hasCircleChild && isSquare + ? Math.min(rect.width, rect.height) / 2 + : 2; + + return { + id: genId(), + type: 'RECTANGLE', + name: el.getAttribute('aria-label') || 'Icon', + x: rect.left - parentLeft, + y: rect.top - parentTop, + width: rect.width, + height: rect.height, + opacity: parseFloat(cs.opacity) || 1, + fills: [{ type: 'SOLID', color: toFigmaColor(iconColor), opacity: iconColor.a }], + strokes: [], + strokeWeight: 0, + strokeAlign: 'INSIDE', + effects: [], + cornerRadius, + clipsContent: false, + }; + } + // Pure-text leaf: no child elements, has text content if (el.childElementCount === 0 && el.textContent.trim()) { const textNode = makeTextNode(el, rect, cs, parentLeft, parentTop); @@ -414,7 +479,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) { stackChildAlignSelf: 'AUTO', characters: displayText, style: { - fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(), + fontFamily: sanitizeFontFamily(cs.fontFamily), fontPostScriptName: null, fontSize: fs, fontWeight: parseInt(cs.fontWeight) || 400, @@ -451,7 +516,7 @@ export function domTraversalFn(viewportWidth, viewportHeight) { stackChildAlignSelf: 'AUTO', characters: text, style: { - fontFamily: (cs.fontFamily || 'Inter').split(',')[0].replace(/['"]/g, '').trim(), + fontFamily: sanitizeFontFamily(cs.fontFamily), fontPostScriptName: null, fontSize: fs, fontWeight: parseInt(cs.fontWeight) || 400, @@ -509,8 +574,8 @@ export function domTraversalFn(viewportWidth, viewportHeight) { type: 'FRAME', name: 'Converted Screen', x: 0, y: 0, - width: viewportWidth, - height: viewportHeight, + width: Math.max(viewportWidth || 0, 100), + height: Math.max(viewportHeight || 0, 100), opacity: 1, fills: bodyBg ? [{ type: 'SOLID', color: toFigmaColor(bodyBg), opacity: bodyBg.a }] diff --git a/src/hooks/useFigmaPaste.js b/src/hooks/useFigmaPaste.js index 6cc4ba3..fc3c56d 100644 --- a/src/hooks/useFigmaPaste.js +++ b/src/hooks/useFigmaPaste.js @@ -84,7 +84,12 @@ export function useFigmaPaste({ handlePaste, addScreenAtCenter }) { frame.imageDataUrl, frame.frameName, i * (220 + GAP), - { figmaSource: frameFigmaSource, sourceHtml: frame.html }, + { + figmaSource: frameFigmaSource, + sourceHtml: frame.html, + sourceWidth: frame.width, + sourceHeight: frame.height, + }, ); } diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js index 7d50bcb..28af1a8 100644 --- a/src/hooks/useScreenManager.js +++ b/src/hooks/useScreenManager.js @@ -42,6 +42,8 @@ function makeScreen(overrides = {}) { figmaSource: null, svgContent: null, sourceHtml: null, + sourceWidth: null, + sourceHeight: null, wireframe: null, ...overrides, }; diff --git a/src/utils/copyToFigma.js b/src/utils/copyToFigma.js index 02f7a02..535e0d2 100644 --- a/src/utils/copyToFigma.js +++ b/src/utils/copyToFigma.js @@ -200,6 +200,19 @@ function parseHtmlViewport(html) { }; } +function resolveViewport(screen) { + const storedWidth = Number.isFinite(screen.sourceWidth) ? screen.sourceWidth : null; + const storedHeight = Number.isFinite(screen.sourceHeight) ? screen.sourceHeight : null; + if (storedWidth && storedHeight) { + return { width: storedWidth, height: storedHeight }; + } + const fromHtml = parseHtmlViewport(screen.sourceHtml || ""); + return { + width: storedWidth || fromHtml.width, + height: storedHeight || fromHtml.height, + }; +} + /** * Copies a single screen to the clipboard in Figma's native binary format. * When pasted in Figma, this produces editable frames with proper fills, @@ -217,7 +230,7 @@ export async function copyScreenForFigmaEditable(screen) { const { htmlToRawNodeTree } = await import("./htmlToFigmaNodes"); const { copyAsFigmaClipboard } = await import("./figmaClipboard"); - const viewport = parseHtmlViewport(screen.sourceHtml); + const viewport = resolveViewport(screen); const rootNode = await htmlToRawNodeTree(screen.sourceHtml, { width: viewport.width, height: viewport.height, @@ -245,7 +258,7 @@ export async function copyScreensForFigmaEditable(screens) { const GAP = 40; if (withHtml.length === 1) { - const viewport = parseHtmlViewport(withHtml[0].sourceHtml); + const viewport = resolveViewport(withHtml[0]); const rootNode = await htmlToRawNodeTree(withHtml[0].sourceHtml, { width: viewport.width, height: viewport.height, @@ -265,7 +278,7 @@ export async function copyScreensForFigmaEditable(screens) { let maxHeight = 852; for (let i = 0; i < withHtml.length; i++) { const s = withHtml[i]; - const vp = parseHtmlViewport(s.sourceHtml); + const vp = resolveViewport(s); if (vp.width > maxWidth) maxWidth = vp.width; if (vp.height > maxHeight) maxHeight = vp.height; const rootNode = await htmlToRawNodeTree(s.sourceHtml, { diff --git a/src/utils/importFlow.js b/src/utils/importFlow.js index ea1fb97..35d9aa3 100644 --- a/src/utils/importFlow.js +++ b/src/utils/importFlow.js @@ -46,6 +46,8 @@ export function importFlow(fileText) { if (screen.figmaSource === undefined) screen.figmaSource = null; if (screen.svgContent === undefined) screen.svgContent = null; if (screen.sourceHtml === undefined) screen.sourceHtml = null; + if (screen.sourceWidth === undefined) screen.sourceWidth = null; + if (screen.sourceHeight === undefined) screen.sourceHeight = null; if (screen.wireframe === undefined) screen.wireframe = null; if (Array.isArray(screen.hotspots)) { for (const hs of screen.hotspots) {