From d688d04bef3ec98cacb5853fdd9f0fa2ddabb5fa Mon Sep 17 00:00:00 2001 From: Anton A S Date: Fri, 13 Mar 2026 14:46:18 +0300 Subject: [PATCH] Address PR #94 review: dedup, replace hand-rolled code, cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace calc.ts (150 lines) with expr-eval library - Replace rgbLuminance with culori wcagLuminance - Use @iconify/utils iconToSVG for viewBox normalization in iconify.ts - Extract describe-shared.ts: findAncestorBackground, looksLikeButton, CONTAINER_TYPES - Extract icon-render.ts: shared createIconFromPaths for create.ts and renderer.ts - Inline codegen prompt as TS constant, remove runtime fs.readFile from MCP server - Fix blur effect color alpha (a:1 → a:0) - Encapsulate module-level mutable state in src/ai/tools.ts into RunState class - Add network check to iconify tests (skip gracefully offline) - Remove Caddyfile.dev and components.d.ts from tracking, add to .gitignore --- .gitignore | 3 + Caddyfile.dev | 20 -- bun.lock | 4 + components.d.ts | 120 --------- packages/core/package.json | 2 + packages/core/src/icon-render.ts | 67 +++++ packages/core/src/iconify.ts | 246 ++++++------------ packages/core/src/index.ts | 2 + packages/core/src/render/renderer.ts | 69 +---- packages/core/src/tools/calc.ts | 175 +------------ packages/core/src/tools/create.ts | 83 +----- packages/core/src/tools/describe-issues.ts | 30 +-- .../core/src/tools/describe-layout-issues.ts | 16 +- packages/core/src/tools/describe-shared.ts | 27 ++ packages/core/src/tools/describe.ts | 12 +- packages/core/src/tools/modify.ts | 2 +- .../core/src/tools/prompts/codegen-prompt.ts | 234 +++++++++++++++++ packages/mcp/src/server.ts | 17 +- src/ai/tools.ts | 47 +++- tests/engine/iconify.test.ts | 43 +-- 20 files changed, 513 insertions(+), 706 deletions(-) delete mode 100644 Caddyfile.dev delete mode 100644 components.d.ts create mode 100644 packages/core/src/icon-render.ts create mode 100644 packages/core/src/tools/describe-shared.ts create mode 100644 packages/core/src/tools/prompts/codegen-prompt.ts diff --git a/.gitignore b/.gitignore index 70be2a858..3f52dd5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ packages/collab/.wrangler/ packages/core/vendor/canvaskit-webgpu/ patches/skia-webgpu/ .wrangler/ + +Caddyfile.dev +components.d.ts diff --git a/Caddyfile.dev b/Caddyfile.dev deleted file mode 100644 index 56e1f7645..000000000 --- a/Caddyfile.dev +++ /dev/null @@ -1,20 +0,0 @@ -{ - admin off -} - -:9100 { - header { - Access-Control-Allow-Origin * - Access-Control-Allow-Methods "GET, POST, OPTIONS" - Access-Control-Allow-Headers "Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta" - } - - @options method OPTIONS - handle @options { - respond 204 - } - - reverse_proxy https://ai-proxy.soldatov.dev { - header_up Host ai-proxy.soldatov.dev - } -} diff --git a/bun.lock b/bun.lock index 0281fb9b4..dbad51bef 100644 --- a/bun.lock +++ b/bun.lock @@ -109,9 +109,11 @@ "name": "@open-pencil/core", "version": "0.9.0", "dependencies": { + "@iconify/utils": "^3.1.0", "canvaskit-wasm": "^0.40.0", "culori": "^4.0.2", "diff": "^8.0.3", + "expr-eval": "^2.0.2", "fflate": "^0.8.2", "fontoxpath": "^3.34.0", "fzstd": "^0.1.1", @@ -1408,6 +1410,8 @@ "execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="], + "expr-eval": ["expr-eval@2.0.2", "", {}, "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], diff --git a/components.d.ts b/components.d.ts deleted file mode 100644 index 58774c6c7..000000000 --- a/components.d.ts +++ /dev/null @@ -1,120 +0,0 @@ -/* eslint-disable */ -// @ts-nocheck -// biome-ignore lint: disable -// oxlint-disable -// ------ -// Generated by unplugin-vue-components -// Read more: https://github.com/vuejs/core/pull/3399 - -export {} - -/* prettier-ignore */ -declare module 'vue' { - export interface GlobalComponents { - AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default'] - AppMenu: typeof import('./src/components/AppMenu.vue')['default'] - AppSelect: typeof import('./src/components/AppSelect.vue')['default'] - AppToast: typeof import('./src/components/AppToast.vue')['default'] - CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default'] - ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default'] - ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default'] - ChatPanel: typeof import('./src/components/ChatPanel.vue')['default'] - CodePanel: typeof import('./src/components/CodePanel.vue')['default'] - CollabPanel: typeof import('./src/components/CollabPanel.vue')['default'] - ColorInput: typeof import('./src/components/ColorInput.vue')['default'] - ColorPicker: typeof import('./src/components/ColorPicker.vue')['default'] - DesignPanel: typeof import('./src/components/DesignPanel.vue')['default'] - EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default'] - EffectsSection: typeof import('./src/components/properties/EffectsSection.vue')['default'] - ExportSection: typeof import('./src/components/properties/ExportSection.vue')['default'] - FillPicker: typeof import('./src/components/FillPicker.vue')['default'] - FillSection: typeof import('./src/components/properties/FillSection.vue')['default'] - FontPicker: typeof import('./src/components/FontPicker.vue')['default'] - HsvColorArea: typeof import('./src/components/HsvColorArea.vue')['default'] - IconLucideALargeSmall: typeof import('~icons/lucide/a-large-small')['default'] - IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] - IconLucideAlignCenter: typeof import('~icons/lucide/align-center')['default'] - IconLucideAlignHorizontalJustifyCenter: typeof import('~icons/lucide/align-horizontal-justify-center')['default'] - IconLucideAlignHorizontalJustifyEnd: typeof import('~icons/lucide/align-horizontal-justify-end')['default'] - IconLucideAlignHorizontalJustifyStart: typeof import('~icons/lucide/align-horizontal-justify-start')['default'] - IconLucideAlignLeft: typeof import('~icons/lucide/align-left')['default'] - IconLucideAlignRight: typeof import('~icons/lucide/align-right')['default'] - IconLucideAlignVerticalJustifyCenter: typeof import('~icons/lucide/align-vertical-justify-center')['default'] - IconLucideAlignVerticalJustifyEnd: typeof import('~icons/lucide/align-vertical-justify-end')['default'] - IconLucideAlignVerticalJustifyStart: typeof import('~icons/lucide/align-vertical-justify-start')['default'] - IconLucideArrowDown: typeof import('~icons/lucide/arrow-down')['default'] - IconLucideArrowRight: typeof import('~icons/lucide/arrow-right')['default'] - IconLucideBaseline: typeof import('~icons/lucide/baseline')['default'] - IconLucideBlend: typeof import('~icons/lucide/blend')['default'] - IconLucideBold: typeof import('~icons/lucide/bold')['default'] - IconLucideBot: typeof import('~icons/lucide/bot')['default'] - IconLucideCheck: typeof import('~icons/lucide/check')['default'] - IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'] - IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] - IconLucideClipboardCopy: typeof import('~icons/lucide/clipboard-copy')['default'] - IconLucideCode: typeof import('~icons/lucide/code')['default'] - IconLucideCopy: typeof import('~icons/lucide/copy')['default'] - IconLucideEye: typeof import('~icons/lucide/eye')['default'] - IconLucideEyeOff: typeof import('~icons/lucide/eye-off')['default'] - IconLucideFile: typeof import('~icons/lucide/file')['default'] - IconLucideFlipHorizontal: typeof import('~icons/lucide/flip-horizontal')['default'] - IconLucideFlipVertical: typeof import('~icons/lucide/flip-vertical')['default'] - IconLucideFolderPlus: typeof import('~icons/lucide/folder-plus')['default'] - IconLucideImage: typeof import('~icons/lucide/image')['default'] - IconLucideItalic: typeof import('~icons/lucide/italic')['default'] - IconLucideLayers: typeof import('~icons/lucide/layers')['default'] - IconLucideLayoutGrid: typeof import('~icons/lucide/layout-grid')['default'] - IconLucideLink: typeof import('~icons/lucide/link')['default'] - IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle')['default'] - IconLucideMenu: typeof import('~icons/lucide/menu')['default'] - IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default'] - IconLucideMinus: typeof import('~icons/lucide/minus')['default'] - IconLucidePlay: typeof import('~icons/lucide/play')['default'] - IconLucidePlus: typeof import('~icons/lucide/plus')['default'] - IconLucideRadius: typeof import('~icons/lucide/radius')['default'] - IconLucideRedo2: typeof import('~icons/lucide/redo2')['default'] - IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default'] - IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default'] - IconLucideSearch: typeof import('~icons/lucide/search')['default'] - IconLucideSend: typeof import('~icons/lucide/send')['default'] - IconLucideSettings: typeof import('~icons/lucide/settings')['default'] - IconLucideSettings2: typeof import('~icons/lucide/settings2')['default'] - IconLucideShare2: typeof import('~icons/lucide/share2')['default'] - IconLucideSidebar: typeof import('~icons/lucide/sidebar')['default'] - IconLucideSlidersHorizontal: typeof import('~icons/lucide/sliders-horizontal')['default'] - IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default'] - IconLucideSquare: typeof import('~icons/lucide/square')['default'] - IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default'] - IconLucideTrash2: typeof import('~icons/lucide/trash2')['default'] - IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default'] - IconLucideUnderline: typeof import('~icons/lucide/underline')['default'] - IconLucideUndo2: typeof import('~icons/lucide/undo2')['default'] - IconLucideUnlink: typeof import('~icons/lucide/unlink')['default'] - IconLucideUsers: typeof import('~icons/lucide/users')['default'] - IconLucideWrapText: typeof import('~icons/lucide/wrap-text')['default'] - IconLucideX: typeof import('~icons/lucide/x')['default'] - LayersPanel: typeof import('./src/components/LayersPanel.vue')['default'] - LayerTree: typeof import('./src/components/LayerTree.vue')['default'] - LayoutSection: typeof import('./src/components/properties/LayoutSection.vue')['default'] - MobileDrawer: typeof import('./src/components/MobileDrawer.vue')['default'] - MobileHud: typeof import('./src/components/MobileHud.vue')['default'] - NodeContextMenuContent: typeof import('./src/components/NodeContextMenuContent.vue')['default'] - PageSection: typeof import('./src/components/properties/PageSection.vue')['default'] - PagesPanel: typeof import('./src/components/PagesPanel.vue')['default'] - PositionSection: typeof import('./src/components/properties/PositionSection.vue')['default'] - PropertiesPanel: typeof import('./src/components/PropertiesPanel.vue')['default'] - ProviderSelect: typeof import('./src/components/chat/ProviderSelect.vue')['default'] - ProviderSettings: typeof import('./src/components/chat/ProviderSettings.vue')['default'] - ProviderSetup: typeof import('./src/components/chat/ProviderSetup.vue')['default'] - RouterLink: typeof import('vue-router')['RouterLink'] - RouterView: typeof import('vue-router')['RouterView'] - SafariBanner: typeof import('./src/components/SafariBanner.vue')['default'] - ScrubInput: typeof import('./src/components/ScrubInput.vue')['default'] - StrokeSection: typeof import('./src/components/properties/StrokeSection.vue')['default'] - TabBar: typeof import('./src/components/TabBar.vue')['default'] - Toolbar: typeof import('./src/components/Toolbar.vue')['default'] - TypographySection: typeof import('./src/components/properties/TypographySection.vue')['default'] - VariablesDialog: typeof import('./src/components/VariablesDialog.vue')['default'] - VariablesSection: typeof import('./src/components/properties/VariablesSection.vue')['default'] - } -} diff --git a/packages/core/package.json b/packages/core/package.json index 68e48cff9..4869327b4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -157,9 +157,11 @@ "types": "./dist/index.d.ts" }, "dependencies": { + "@iconify/utils": "^3.1.0", "canvaskit-wasm": "^0.40.0", "culori": "^4.0.2", "diff": "^8.0.3", + "expr-eval": "^2.0.2", "fflate": "^0.8.2", "fontoxpath": "^3.34.0", "fzstd": "^0.1.1", diff --git a/packages/core/src/icon-render.ts b/packages/core/src/icon-render.ts new file mode 100644 index 000000000..4d89545c5 --- /dev/null +++ b/packages/core/src/icon-render.ts @@ -0,0 +1,67 @@ +import { parseColor } from './color' + +import type { Color } from './types' +import type { SceneGraph, SceneNode, Stroke } from './scene-graph' +import type { IconData } from './iconify' + +const STROKE_CAP_MAP: Record = { + butt: 'NONE', round: 'ROUND', square: 'SQUARE' +} + +const STROKE_JOIN_MAP: Record = { + miter: 'MITER', round: 'ROUND', bevel: 'BEVEL' +} + +export function createIconFromPaths( + graph: SceneGraph, + icon: IconData, + name: string, + size: number, + color: Color, + parentId: string, + overrides?: Partial +): SceneNode { + const frame = graph.createNode('FRAME', parentId, { + name: `Icon / ${name}`, + width: size, + height: size, + fills: [], + ...overrides + }) + + for (const path of icon.paths) { + const vector = graph.createNode('VECTOR', frame.id, { + name: 'path', + width: size, + height: size, + vectorNetwork: path.vectorNetwork + }) + vector.x = 0 + vector.y = 0 + + if (path.fill) { + const fillColor = path.fill === 'currentColor' ? color : parseColor(path.fill) + graph.updateNode(vector.id, { + fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }] + }) + } else { + graph.updateNode(vector.id, { fills: [] }) + } + + if (path.stroke) { + const strokeColor = path.stroke === 'currentColor' ? color : parseColor(path.stroke) + const stroke: Stroke = { + color: strokeColor, + weight: path.strokeWidth, + opacity: 1, + visible: true, + align: 'CENTER', + cap: STROKE_CAP_MAP[path.strokeCap] ?? 'NONE', + join: STROKE_JOIN_MAP[path.strokeJoin] ?? 'MITER' + } + graph.updateNode(vector.id, { strokes: [stroke] }) + } + } + + return frame +} diff --git a/packages/core/src/iconify.ts b/packages/core/src/iconify.ts index d5d7a1f0b..833afc9df 100644 --- a/packages/core/src/iconify.ts +++ b/packages/core/src/iconify.ts @@ -1,4 +1,5 @@ import svgpath from 'svgpath' +import { iconToSVG } from '@iconify/utils' import { parseSVGPath } from './svg-path-parse' @@ -10,15 +11,10 @@ const FETCH_TIMEOUT_MS = 10_000 interface PathInfo { d: string fill: string | null - fillExplicit: boolean stroke: string | null - strokeExplicit: boolean strokeWidth: number - strokeWidthExplicit: boolean strokeCap: string - strokeCapExplicit: boolean strokeJoin: string - strokeJoinExplicit: boolean fillRule: WindingRule } @@ -78,164 +74,86 @@ function num(tag: string, attr: string, fallback = 0): number { return v !== null ? parseFloat(v) : fallback } -function circleToD(tag: string): string | null { - const cx = num(tag, 'cx') - const cy = num(tag, 'cy') - const r = num(tag, 'r') - if (r <= 0) return null - return `M${cx - r},${cy}A${r},${r},0,1,0,${cx + r},${cy}A${r},${r},0,1,0,${cx - r},${cy}Z` -} - -function ellipseToD(tag: string): string | null { - const cx = num(tag, 'cx') - const cy = num(tag, 'cy') - const rx = num(tag, 'rx') - const ry = num(tag, 'ry') - if (rx <= 0 || ry <= 0) return null - return `M${cx - rx},${cy}A${rx},${ry},0,1,0,${cx + rx},${cy}A${rx},${ry},0,1,0,${cx - rx},${cy}Z` -} - -function rectToD(tag: string): string | null { - const x = num(tag, 'x') - const y = num(tag, 'y') - const w = num(tag, 'width') - const h = num(tag, 'height') - if (w <= 0 || h <= 0) return null - const rx = Math.min(num(tag, 'rx'), w / 2) - const ry = Math.min(num(tag, 'ry', rx), h / 2) - if (rx > 0 || ry > 0) { - const arx = rx > 0 ? rx : ry - const ary = ry > 0 ? ry : rx - return `M${x + arx},${y}H${x + w - arx}A${arx},${ary},0,0,1,${x + w},${y + ary}V${y + h - ary}A${arx},${ary},0,0,1,${x + w - arx},${y + h}H${x + arx}A${arx},${ary},0,0,1,${x},${y + h - ary}V${y + ary}A${arx},${ary},0,0,1,${x + arx},${y}Z` +function shapeToD(tagName: string, tag: string): string | null { + switch (tagName) { + case 'circle': { + const cx = num(tag, 'cx'), cy = num(tag, 'cy'), r = num(tag, 'r') + return r > 0 ? `M${cx - r},${cy}A${r},${r},0,1,0,${cx + r},${cy}A${r},${r},0,1,0,${cx - r},${cy}Z` : null + } + case 'ellipse': { + const cx = num(tag, 'cx'), cy = num(tag, 'cy'), rx = num(tag, 'rx'), ry = num(tag, 'ry') + return rx > 0 && ry > 0 ? `M${cx - rx},${cy}A${rx},${ry},0,1,0,${cx + rx},${cy}A${rx},${ry},0,1,0,${cx - rx},${cy}Z` : null + } + case 'rect': { + const x = num(tag, 'x'), y = num(tag, 'y'), w = num(tag, 'width'), h = num(tag, 'height') + if (w <= 0 || h <= 0) return null + const rx = Math.min(num(tag, 'rx'), w / 2), ry = Math.min(num(tag, 'ry', rx), h / 2) + if (rx > 0 || ry > 0) { + const arx = rx || ry, ary = ry || rx + return `M${x + arx},${y}H${x + w - arx}A${arx},${ary},0,0,1,${x + w},${y + ary}V${y + h - ary}A${arx},${ary},0,0,1,${x + w - arx},${y + h}H${x + arx}A${arx},${ary},0,0,1,${x},${y + h - ary}V${y + ary}A${arx},${ary},0,0,1,${x + arx},${y}Z` + } + return `M${x},${y}H${x + w}V${y + h}H${x}Z` + } + case 'line': { + const x1 = num(tag, 'x1'), y1 = num(tag, 'y1'), x2 = num(tag, 'x2'), y2 = num(tag, 'y2') + return `M${x1},${y1}L${x2},${y2}` + } + case 'polygon': + case 'polyline': { + const points = attrValue(tag, 'points') + if (!points) return null + const nums = points.trim().split(/[\s,]+/).map(Number) + if (nums.length < 4) return null + let d = `M${nums[0]},${nums[1]}` + for (let i = 2; i < nums.length; i += 2) d += `L${nums[i]},${nums[i + 1]}` + if (tagName === 'polygon') d += 'Z' + return d + } + default: + return null } - return `M${x},${y}H${x + w}V${y + h}H${x}Z` } -function lineToD(tag: string): string | null { - const x1 = num(tag, 'x1') - const y1 = num(tag, 'y1') - const x2 = num(tag, 'x2') - const y2 = num(tag, 'y2') - return `M${x1},${y1}L${x2},${y2}` -} - -function polyToD(tag: string, close: boolean): string | null { - const points = attrValue(tag, 'points') - if (!points) return null - const nums = points.trim().split(/[\s,]+/).map(Number) - if (nums.length < 4) return null - let d = `M${nums[0]},${nums[1]}` - for (let i = 2; i < nums.length; i += 2) { - d += `L${nums[i]},${nums[i + 1]}` - } - if (close) d += 'Z' - return d -} - -const SHAPE_CONVERTERS: Record string | null> = { - circle: circleToD, - ellipse: ellipseToD, - rect: rectToD, - line: lineToD, - polygon: (tag) => polyToD(tag, true), - polyline: (tag) => polyToD(tag, false) +function resolveAttr(explicit: string | null, group: string | null, fallback: string | null): string | null { + if (explicit !== null) return explicit === 'none' ? null : explicit + if (group !== null) return group === 'none' ? null : group + return fallback } function extractPaths(svgBody: string): PathInfo[] { + const groupAttrs = { fill: null as string | null, stroke: null as string | null, strokeWidth: null as string | null, strokeCap: null as string | null, strokeJoin: null as string | null } + const groupRe = /]*>/g + let gm + while ((gm = groupRe.exec(svgBody)) !== null) { + groupAttrs.fill ??= attrValue(gm[0], 'fill') + groupAttrs.stroke ??= attrValue(gm[0], 'stroke') + groupAttrs.strokeWidth ??= attrValue(gm[0], 'stroke-width') + groupAttrs.strokeCap ??= attrValue(gm[0], 'stroke-linecap') + groupAttrs.strokeJoin ??= attrValue(gm[0], 'stroke-linejoin') + } + const result: PathInfo[] = [] const shapeRe = /<(path|circle|ellipse|rect|line|polygon|polyline)\b[^>]*>/g let match while ((match = shapeRe.exec(svgBody)) !== null) { - const tag = match[0] - const tagName = match[1] - - let d: string | null - if (tagName === 'path') { - d = attrValue(tag, 'd') - } else { - d = SHAPE_CONVERTERS[tagName](tag) - } + const tag = match[0], tagName = match[1] + const d = tagName === 'path' ? attrValue(tag, 'd') : shapeToD(tagName, tag) if (!d) continue - const fillAttr = attrValue(tag, 'fill') - const strokeAttr = attrValue(tag, 'stroke') - const strokeWidthAttr = attrValue(tag, 'stroke-width') - const strokeCapAttr = attrValue(tag, 'stroke-linecap') - const strokeJoinAttr = attrValue(tag, 'stroke-linejoin') const fillRuleAttr = attrValue(tag, 'fill-rule') - result.push({ d, - fill: fillAttr === 'none' ? null : (fillAttr ?? null), - fillExplicit: fillAttr !== null, - stroke: strokeAttr === 'none' ? null : (strokeAttr ?? null), - strokeExplicit: strokeAttr !== null, - strokeWidth: strokeWidthAttr ? parseFloat(strokeWidthAttr) : 1, - strokeWidthExplicit: strokeWidthAttr !== null, - strokeCap: strokeCapAttr ?? 'butt', - strokeCapExplicit: strokeCapAttr !== null, - strokeJoin: strokeJoinAttr ?? 'miter', - strokeJoinExplicit: strokeJoinAttr !== null, + fill: resolveAttr(attrValue(tag, 'fill'), groupAttrs.fill, 'currentColor'), + stroke: resolveAttr(attrValue(tag, 'stroke'), groupAttrs.stroke, null), + strokeWidth: parseFloat(attrValue(tag, 'stroke-width') ?? groupAttrs.strokeWidth ?? '1'), + strokeCap: attrValue(tag, 'stroke-linecap') ?? groupAttrs.strokeCap ?? 'butt', + strokeJoin: attrValue(tag, 'stroke-linejoin') ?? groupAttrs.strokeJoin ?? 'miter', fillRule: fillRuleAttr === 'evenodd' ? 'EVENODD' : 'NONZERO' }) } return result } -interface GroupAttrs { - fill: string | null - stroke: string | null - strokeWidth: string | null - strokeCap: string | null - strokeJoin: string | null -} - -function collectGroupAttrs(svgBody: string): GroupAttrs { - const attrs: GroupAttrs = { fill: null, stroke: null, strokeWidth: null, strokeCap: null, strokeJoin: null } - const groupRe = /]*>/g - let gMatch - while ((gMatch = groupRe.exec(svgBody)) !== null) { - const gTag = gMatch[0] - attrs.fill ??= attrValue(gTag, 'fill') - attrs.stroke ??= attrValue(gTag, 'stroke') - attrs.strokeWidth ??= attrValue(gTag, 'stroke-width') - attrs.strokeCap ??= attrValue(gTag, 'stroke-linecap') - attrs.strokeJoin ??= attrValue(gTag, 'stroke-linejoin') - } - return attrs -} - -function resolveAttr(value: string | null): string | null { - return value === 'none' ? null : value -} - -function inheritGroupAttrs(svgBody: string, paths: PathInfo[]): void { - const g = collectGroupAttrs(svgBody) - - for (const p of paths) { - if (!p.fillExplicit) p.fill = g.fill ? resolveAttr(g.fill) : (p.fill ?? 'currentColor') - if (!p.strokeExplicit && g.stroke) p.stroke = resolveAttr(g.stroke) - if (!p.strokeWidthExplicit && g.strokeWidth) p.strokeWidth = parseFloat(g.strokeWidth) - if (!p.strokeCapExplicit && g.strokeCap) p.strokeCap = g.strokeCap - if (!p.strokeJoinExplicit && g.strokeJoin) p.strokeJoin = g.strokeJoin - } -} - -function scalePaths(paths: PathInfo[], srcW: number, srcH: number, targetSize: number): PathInfo[] { - if (srcW === targetSize && srcH === targetSize) return paths - const sx = targetSize / srcW - const sy = targetSize / srcH - return paths.map((p) => ({ - ...p, - d: svgpath(p.d).scale(sx, sy).round(2).toString(), - strokeWidth: p.strokeWidth * Math.min(sx, sy) - })) -} - -function fetchWithTimeout(url: string): Promise { - return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) -} - function buildIconData( iconEntry: IconifyIconEntry, prefix: string, @@ -244,29 +162,40 @@ function buildIconData( defaultH: number, size: number ): IconData { - const srcW = iconEntry.width ?? defaultW - const srcH = iconEntry.height ?? defaultH + const rendered = iconToSVG({ + body: iconEntry.body, + width: iconEntry.width ?? defaultW, + height: iconEntry.height ?? defaultH + }) + const [, , vbW, vbH] = rendered.viewBox + const sx = size / vbW + const sy = size / vbH - let pathInfos = extractPaths(iconEntry.body) - inheritGroupAttrs(iconEntry.body, pathInfos) - pathInfos = scalePaths(pathInfos, srcW, srcH, size) + const pathInfos = extractPaths(rendered.body) return { prefix, name: iconName, width: size, height: size, - paths: pathInfos.map((p) => ({ - vectorNetwork: parseSVGPath(p.d, p.fillRule), - fill: p.fill, - stroke: p.stroke, - strokeWidth: p.strokeWidth, - strokeCap: p.strokeCap, - strokeJoin: p.strokeJoin - })) + paths: pathInfos.map((p) => { + const scaledD = (sx === 1 && sy === 1) ? p.d : svgpath(p.d).scale(sx, sy).round(2).toString() + return { + vectorNetwork: parseSVGPath(scaledD, p.fillRule), + fill: p.fill, + stroke: p.stroke, + strokeWidth: p.strokeWidth * Math.min(sx, sy), + strokeCap: p.strokeCap, + strokeJoin: p.strokeJoin + } + }) } } +function fetchWithTimeout(url: string): Promise { + return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }) +} + export async function fetchIcon(name: string, size = 24): Promise { const results = await fetchIcons([name], size) const result = results.get(name) @@ -274,10 +203,6 @@ export async function fetchIcon(name: string, size = 24): Promise { return result } -/** - * Batch-fetch multiple icons. Groups by prefix to minimize HTTP requests - * (one request per prefix, e.g. `lucide.json?icons=heart,home,star`). - */ export async function fetchIcons(names: string[], size = 24): Promise> { const results = new Map() const toFetch = new Map() @@ -347,9 +272,6 @@ export async function searchIcons(query: string, options?: { } } -/** - * Batch-search multiple queries in parallel. - */ export async function searchIconsBatch(queries: string[], options?: { limit?: number prefix?: string diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1f97a038e..e51f65f36 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -243,3 +243,5 @@ export { parseVarint, FIG_WIRE_MAGIC } from './kiwi' + +export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt' diff --git a/packages/core/src/render/renderer.ts b/packages/core/src/render/renderer.ts index 2b809abb9..e0afcfc52 100644 --- a/packages/core/src/render/renderer.ts +++ b/packages/core/src/render/renderer.ts @@ -1,11 +1,10 @@ import { parseColor, colorToFill } from '../color' import { TRANSPARENT } from '../constants' import { fetchIcons } from '../iconify' +import { createIconFromPaths } from '../icon-render' import { isTreeNode } from './tree' -import type { IconData } from '../iconify' import type { SceneGraph, SceneNode, NodeType, LayoutMode, GridTrack, Stroke } from '../scene-graph' -import type { Color } from '../types' import type { TreeNode } from './tree' const TYPE_MAP: Partial> = { @@ -120,75 +119,15 @@ async function renderIconNode( throw new Error(`Icon "${iconName}" not found`) } - return createIconNode(graph, icon, iconName, size, parsedColor, parentId, props) -} - -function createIconNode( - graph: SceneGraph, - icon: IconData, - iconName: string, - size: number, - color: Color, - parentId: string, - props: Record -): SceneNode { const parent = graph.getNode(parentId) const parentLayout = parent?.layoutMode ?? 'NONE' - const overrides: Partial = { - name: (props.label as string | undefined) ?? `Icon / ${iconName}`, - width: size, - height: size, - fills: [] - } + const overrides: Partial = {} + if (props.label) overrides.name = props.label as string const { w, h } = applySizeOverrides(props, overrides, parentLayout) if (typeof w !== 'number') overrides.width = size if (typeof h !== 'number') overrides.height = size - const frame = graph.createNode('FRAME', parentId, overrides) - for (const path of icon.paths) { - const vector = graph.createNode('VECTOR', frame.id, { - name: 'path', - width: size, - height: size, - vectorNetwork: path.vectorNetwork - }) - vector.x = 0 - vector.y = 0 - - if (path.fill) { - const fillColor = path.fill === 'currentColor' ? color : parseColor(path.fill) - graph.updateNode(vector.id, { - fills: [{ type: 'SOLID', color: fillColor, opacity: 1, visible: true }] - }) - } else { - graph.updateNode(vector.id, { fills: [] }) - } - - if (path.stroke) { - const strokeColor = path.stroke === 'currentColor' ? color : parseColor(path.stroke) - graph.updateNode(vector.id, { - strokes: [{ - color: strokeColor, - weight: path.strokeWidth, - opacity: 1, - visible: true, - align: 'CENTER' as const, - cap: ICON_STROKE_CAP[path.strokeCap] ?? 'NONE', - join: ICON_STROKE_JOIN[path.strokeJoin] ?? 'MITER' - }] - }) - } - } - - return frame -} - -const ICON_STROKE_CAP: Record = { - round: 'ROUND', square: 'SQUARE', butt: 'NONE' -} - -const ICON_STROKE_JOIN: Record = { - miter: 'MITER', round: 'ROUND', bevel: 'BEVEL' + return createIconFromPaths(graph, icon, iconName, size, parsedColor, parentId, overrides) } async function renderNode(graph: SceneGraph, tree: TreeNode, parentId: string): Promise { diff --git a/packages/core/src/tools/calc.ts b/packages/core/src/tools/calc.ts index 386f55043..6285b5851 100644 --- a/packages/core/src/tools/calc.ts +++ b/packages/core/src/tools/calc.ts @@ -1,186 +1,27 @@ +import ExprEval from 'expr-eval' + import { defineTool } from './schema' -/** - * Recursive descent parser for safe arithmetic expressions. - * Supports: + - * / % ** ( ) and Math.min/max/floor/ceil/round/abs/sqrt/pow - */ - -const MATH_FUNCTIONS = new Map number>([ - ['Math.min', (...args) => Math.min(...args)], - ['Math.max', (...args) => Math.max(...args)], - ['Math.floor', (n) => Math.floor(n)], - ['Math.ceil', (n) => Math.ceil(n)], - ['Math.round', (n) => Math.round(n)], - ['Math.abs', (n) => Math.abs(n)], - ['Math.sqrt', (n) => Math.sqrt(n)], - ['Math.pow', (base, exp) => Math.pow(base, exp)], -]) - -class Parser { - private pos = 0 - constructor(private input: string) {} - - parse(): number { - this.skipWhitespace() - const result = this.parseAddSub() - this.skipWhitespace() - if (this.pos < this.input.length) { - throw new Error(`Unexpected character '${this.input[this.pos]}' at position ${this.pos}`) - } - return result - } - - private parseAddSub(): number { - let left = this.parseMulDiv() - this.skipWhitespace() - while (this.pos < this.input.length && (this.peek() === '+' || this.peek() === '-')) { - const op = this.advance() - const right = this.parseMulDiv() - left = op === '+' ? left + right : left - right - this.skipWhitespace() - } - return left - } - - private parseMulDiv(): number { - let left = this.parseExponent() - this.skipWhitespace() - while (this.pos < this.input.length && (this.peek() === '*' || this.peek() === '/' || this.peek() === '%')) { - const op = this.advance() - const right = this.parseExponent() - if (op === '*') left *= right - else if (op === '/') left /= right - else left %= right - this.skipWhitespace() - } - return left - } - - private parseExponent(): number { - let base = this.parseUnary() - this.skipWhitespace() - if (this.pos < this.input.length - 1 && this.input[this.pos] === '*' && this.input[this.pos + 1] === '*') { - this.pos += 2 - const exp = this.parseUnary() - base = base ** exp - } - return base - } - - private parseUnary(): number { - this.skipWhitespace() - if (this.peek() === '-') { - this.advance() - return -this.parseUnary() - } - if (this.peek() === '+') { - this.advance() - return this.parseUnary() - } - return this.parsePrimary() - } - - private parsePrimary(): number { - this.skipWhitespace() - - if (this.peek() === '(') { - this.advance() - const val = this.parseAddSub() - this.skipWhitespace() - this.expect(')') - return val - } - - if (this.input.startsWith('Math.', this.pos)) { - return this.parseMathFunction() - } - - return this.parseNumber() - } - - private parseMathFunction(): number { - const start = this.pos - while (this.pos < this.input.length && /[A-Za-z.]/.test(this.input[this.pos])) { - this.pos++ - } - const name = this.input.slice(start, this.pos) - const fn = MATH_FUNCTIONS.get(name) - if (!fn) throw new Error(`Unknown function: ${name}`) - - this.skipWhitespace() - this.expect('(') - const args: number[] = [] - this.skipWhitespace() - if (this.peek() !== ')') { - args.push(this.parseAddSub()) - this.skipWhitespace() - while (this.peek() === ',') { - this.advance() - args.push(this.parseAddSub()) - this.skipWhitespace() - } - } - this.expect(')') - return fn(...args) - } - - private parseNumber(): number { - this.skipWhitespace() - const start = this.pos - if (this.peek() === '-' || this.peek() === '+') this.pos++ - while (this.pos < this.input.length && /[0-9.]/.test(this.input[this.pos])) { - this.pos++ - } - const str = this.input.slice(start, this.pos) - const num = Number(str) - if (str.length === 0 || Number.isNaN(num)) { - throw new Error(`Expected number at position ${start}, got '${this.input.slice(start, start + 10)}'`) - } - return num - } - - private skipWhitespace(): void { - while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) { - this.pos++ - } - } - - private peek(): string { - return this.input[this.pos] ?? '' - } - - private advance(): string { - return this.input[this.pos++] ?? '' - } - - private expect(ch: string): void { - if (this.input[this.pos] !== ch) { - throw new Error(`Expected '${ch}' at position ${this.pos}, got '${this.input[this.pos] ?? 'EOF'}'`) - } - this.pos++ - } -} - -function evaluate(expr: string): number { - return new Parser(expr).parse() -} +const parser = new ExprEval.Parser() export const calc = defineTool({ name: 'calc', description: 'Arithmetic calculator. ALWAYS use this instead of mental math for layout calculations. ' + 'Evaluates a math expression and returns the numeric result. ' + - 'Examples: "844 - 56 - 96 - 82" → 610, "Math.floor(390 * 0.6)" → 234', + 'Supports: + - * / % ** ( ) min max floor ceil round abs sqrt pow. ' + + 'Examples: "844 - 56 - 96 - 82" → 610, "floor(390 * 0.6)" → 234', params: { expr: { type: 'string', - description: 'Math expression, e.g. "844 - 56 - 96 - 82" or "Math.min(300, 400 - 2 * 20)"', + description: 'Math expression, e.g. "844 - 56 - 96 - 82" or "min(300, 400 - 2 * 20)"', required: true } }, execute: (_figma, { expr }) => { try { - const result = evaluate(expr) + const normalized = expr.replace(/Math\./g, '') + const result = parser.evaluate(normalized) if (!Number.isFinite(result)) { return { error: `Expression "${expr}" produced ${String(result)}` } } diff --git a/packages/core/src/tools/create.ts b/packages/core/src/tools/create.ts index a61102af2..ea0782a3a 100644 --- a/packages/core/src/tools/create.ts +++ b/packages/core/src/tools/create.ts @@ -1,11 +1,11 @@ import { parseColor } from '../color' +import { createIconFromPaths } from '../icon-render' import { fetchIcons, searchIconsBatch } from '../iconify' import { defineTool, nodeSummary } from './schema' -import type { FigmaAPI, FigmaNodeProxy } from '../figma-api' -import type { Color } from '../types' -import type { Stroke, StrokeCap, StrokeJoin, VectorNetwork } from '../scene-graph' +import type { FigmaNodeProxy } from '../figma-api' +import type { VectorNetwork } from '../scene-graph' export const createShape = defineTool({ name: 'create_shape', @@ -190,17 +190,6 @@ export const createSlice = defineTool({ } }) -const STROKE_CAP_MAP: Record = { - butt: 'NONE', - round: 'ROUND', - square: 'SQUARE' -} - -const STROKE_JOIN_MAP: Record = { - miter: 'MITER', - round: 'ROUND', - bevel: 'BEVEL' -} export const fetchIconsTool = defineTool({ name: 'fetch_icons', @@ -229,70 +218,7 @@ export const fetchIconsTool = defineTool({ } }) -import type { IconData } from '../iconify' -function createIconFrame( - figma: FigmaAPI, - icon: IconData, - name: string, - size: number, - parsedColor: Color, - parentId?: string -): FigmaNodeProxy { - const frame = figma.createFrame() - frame.name = `Icon / ${name}` - frame.resize(size, size) - frame.fills = [] - - if (parentId) { - const parent = figma.getNodeById(parentId) - if (parent) { - parent.appendChild(frame) - frame.x = 0 - frame.y = 0 - } - } - - for (const path of icon.paths) { - const vector = figma.createVector() - vector.name = 'path' - vector.resize(size, size) - frame.appendChild(vector) - vector.x = 0 - vector.y = 0 - - figma.graph.updateNode(vector.id, { - vectorNetwork: path.vectorNetwork - } as Record) - - if (path.fill) { - vector.fills = [{ - type: 'SOLID', - color: path.fill === 'currentColor' ? parsedColor : parseColor(path.fill), - opacity: 1, - visible: true - }] - } else { - vector.fills = [] - } - - if (path.stroke) { - const strokeColor = path.stroke === 'currentColor' ? parsedColor : parseColor(path.stroke) - const stroke: Stroke = { - color: strokeColor, - weight: path.strokeWidth, - opacity: 1, - visible: true, - align: 'CENTER', - cap: STROKE_CAP_MAP[path.strokeCap] ?? 'NONE', - join: STROKE_JOIN_MAP[path.strokeJoin] ?? 'MITER' - } - vector.strokes = [stroke] - } - } - - return frame -} export const insertIcon = defineTool({ name: 'insert_icon', @@ -336,7 +262,8 @@ export const insertIcon = defineTool({ notFound.push(name) continue } - const frame = createIconFrame(figma, icon, name, size, parsedColor, args.parent_id) + const parentId = args.parent_id ?? figma.currentPage.id + const frame = createIconFromPaths(figma.graph, icon, name, size, parsedColor, parentId) inserted.push({ id: frame.id, name: frame.name, icon: name }) } diff --git a/packages/core/src/tools/describe-issues.ts b/packages/core/src/tools/describe-issues.ts index 03b7b85c2..5bfd828f0 100644 --- a/packages/core/src/tools/describe-issues.ts +++ b/packages/core/src/tools/describe-issues.ts @@ -1,45 +1,21 @@ import { colorDistance, colorToHex } from '../color' +import { CONTAINER_TYPES, findAncestorBackground, looksLikeButton } from './describe-shared' import { detectLayoutIssues } from './describe-layout-issues' -import type { Color } from '../types' import type { SceneGraph, SceneNode } from '../scene-graph' const MIN_FILL_OPACITY = 0.15 const MIN_STROKE_OPACITY = 0.20 const LOW_CONTRAST_THRESHOLD = 15 +const SHAPE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'STAR', 'POLYGON', 'LINE']) +const ICON_MAX_SIZE = 48 export interface DescribeIssue { message: string suggestion?: string } -function findAncestorBackground(node: SceneNode, graph: SceneGraph): Color | null { - let current = node.parentId ? graph.getNode(node.parentId) : null - while (current) { - const solidFill = current.fills.find((f) => f.visible && f.type === 'SOLID' && f.opacity > 0.5) - if (solidFill) return solidFill.color - current = current.parentId ? graph.getNode(current.parentId) : null - } - return null -} - -const CONTAINER_TYPES = new Set(['FRAME', 'COMPONENT', 'INSTANCE']) -const SHAPE_TYPES = new Set(['RECTANGLE', 'ELLIPSE', 'STAR', 'POLYGON', 'LINE']) -const ICON_MAX_SIZE = 48 -const BUTTON_MAX_WIDTH = 200 -const BUTTON_MAX_HEIGHT = 50 -const BUTTON_MIN_HEIGHT = 28 -const BUTTON_MIN_RADIUS = 2 - -function looksLikeButton(node: SceneNode): boolean { - if (!CONTAINER_TYPES.has(node.type)) return false - return node.width <= BUTTON_MAX_WIDTH && - node.height >= BUTTON_MIN_HEIGHT && node.height <= BUTTON_MAX_HEIGHT && - node.cornerRadius >= BUTTON_MIN_RADIUS && - node.childIds.length > 0 -} - function checkEmptyIcon(node: SceneNode, graph: SceneGraph, issues: DescribeIssue[]): void { if (!CONTAINER_TYPES.has(node.type)) return if (node.width > ICON_MAX_SIZE || node.height > ICON_MAX_SIZE || node.childIds.length === 0) return diff --git a/packages/core/src/tools/describe-layout-issues.ts b/packages/core/src/tools/describe-layout-issues.ts index 7e0f16aac..2d4174c4a 100644 --- a/packages/core/src/tools/describe-layout-issues.ts +++ b/packages/core/src/tools/describe-layout-issues.ts @@ -1,24 +1,16 @@ +import { wcagLuminance } from 'culori' + import { colorToHex } from '../color' +import { CONTAINER_TYPES, findAncestorBackground } from './describe-shared' import type { Color } from '../types' import type { SceneGraph, SceneNode } from '../scene-graph' import type { DescribeIssue } from './describe-issues' -const CONTAINER_TYPES = new Set(['FRAME', 'COMPONENT', 'INSTANCE']) const DARK_BG_LUMINANCE = 0.35 function rgbLuminance(c: Color): number { - return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b -} - -function findAncestorBackground(node: SceneNode, graph: SceneGraph): Color | null { - let current = node.parentId ? graph.getNode(node.parentId) : null - while (current) { - const solidFill = current.fills.find((f) => f.visible && f.type === 'SOLID' && f.opacity > 0.5) - if (solidFill) return solidFill.color - current = current.parentId ? graph.getNode(current.parentId) : null - } - return null + return wcagLuminance({ mode: 'rgb', r: c.r, g: c.g, b: c.b }) } interface LayoutContext { diff --git a/packages/core/src/tools/describe-shared.ts b/packages/core/src/tools/describe-shared.ts new file mode 100644 index 000000000..47f2eb363 --- /dev/null +++ b/packages/core/src/tools/describe-shared.ts @@ -0,0 +1,27 @@ +import type { Color } from '../types' +import type { SceneGraph, SceneNode } from '../scene-graph' + +export const CONTAINER_TYPES = new Set(['FRAME', 'COMPONENT', 'INSTANCE']) + +export const BUTTON_MAX_WIDTH = 200 +export const BUTTON_MAX_HEIGHT = 50 +export const BUTTON_MIN_HEIGHT = 28 +export const BUTTON_MIN_RADIUS = 2 + +export function findAncestorBackground(node: SceneNode, graph: SceneGraph): Color | null { + let current = node.parentId ? graph.getNode(node.parentId) : null + while (current) { + const solidFill = current.fills.find((f) => f.visible && f.type === 'SOLID' && f.opacity > 0.5) + if (solidFill) return solidFill.color + current = current.parentId ? graph.getNode(current.parentId) : null + } + return null +} + +export function looksLikeButton(node: SceneNode): boolean { + if (!CONTAINER_TYPES.has(node.type)) return false + if (node.width > BUTTON_MAX_WIDTH || node.height > BUTTON_MAX_HEIGHT || node.height < BUTTON_MIN_HEIGHT) return false + if (node.fills.length === 0 && node.strokes.length === 0) return false + if (node.cornerRadius < BUTTON_MIN_RADIUS) return false + return node.childIds.length > 0 +} diff --git a/packages/core/src/tools/describe.ts b/packages/core/src/tools/describe.ts index 575041301..425f325f4 100644 --- a/packages/core/src/tools/describe.ts +++ b/packages/core/src/tools/describe.ts @@ -1,4 +1,5 @@ import { colorToHex } from '../color' +import { looksLikeButton } from './describe-shared' import { detectIssues } from './describe-issues' @@ -73,18 +74,7 @@ function looksLikeSeparator(node: SceneNode): boolean { return ratio > 10 && Math.min(node.width, node.height) <= 4 } -const BUTTON_MAX_WIDTH = 200 -const BUTTON_MAX_HEIGHT = 50 -const BUTTON_MIN_HEIGHT = 28 -const BUTTON_MIN_RADIUS = 2 -function looksLikeButton(node: SceneNode): boolean { - if (node.type !== 'FRAME' && node.type !== 'COMPONENT' && node.type !== 'INSTANCE') return false - if (node.width > BUTTON_MAX_WIDTH || node.height > BUTTON_MAX_HEIGHT || node.height < BUTTON_MIN_HEIGHT) return false - if (node.fills.length === 0 && node.strokes.length === 0) return false - if (node.cornerRadius < BUTTON_MIN_RADIUS) return false - return node.childIds.length > 0 -} function describeVisual(node: SceneNode): string { const parts: string[] = [] diff --git a/packages/core/src/tools/modify.ts b/packages/core/src/tools/modify.ts index 00a2acfd8..38240d8e0 100644 --- a/packages/core/src/tools/modify.ts +++ b/packages/core/src/tools/modify.ts @@ -81,7 +81,7 @@ export const setEffects = defineTool({ const isBlur = args.type === 'FOREGROUND_BLUR' || args.type === 'BACKGROUND_BLUR' const color = isBlur - ? { r: 0, g: 0, b: 0, a: 1 } + ? { r: 0, g: 0, b: 0, a: 0 } : (args.color ? parseColor(args.color) : { ...DEFAULT_SHADOW_COLOR }) const effect: Effect = { type: args.type as Effect['type'], diff --git a/packages/core/src/tools/prompts/codegen-prompt.ts b/packages/core/src/tools/prompts/codegen-prompt.ts new file mode 100644 index 000000000..a5301a215 --- /dev/null +++ b/packages/core/src/tools/prompts/codegen-prompt.ts @@ -0,0 +1,234 @@ +export const CODEGEN_PROMPT = `# Design to Code + +You convert Figma designs into production frontend code. You have full access to the design document through tools. Never guess — always read the actual design data. + +> Note: Auto-layout and visual property names match the JSX props in the render system. See \`describe\` tool output for semantic role and layout analysis of any node. + +## Workflow + +### Step 1 — Survey + +Understand the full picture before writing any code. + +\`\`\` +get_page_tree → document structure, all top-level frames +get_components → reusable components defined by the designer +list_variables → design tokens (colors, numbers, strings, booleans) +list_collections → variable collections and modes (light/dark, density, etc.) +analyze_colors → color palette, frequencies, which colors use variables +analyze_typography → font stacks, sizes, weights in use +analyze_spacing → gap and padding values, grid compliance +\`\`\` + +After this step you should know: +- How many screens/pages the design has +- What components exist +- What the token system looks like (or if there is none) +- The typographic scale +- The spacing system (4px grid? 8px grid? irregular?) + +### Step 2 — Decompose + +Identify the component architecture. + +\`\`\` +analyze_clusters → find repeated visual patterns that should be components +describe (per node) → semantic role, layout direction, visual properties, issues +get_jsx (per node) → structural JSX to understand nesting and layout +\`\`\` + +Build a component map: +- **Screens** — top-level frames that represent pages/views +- **Components** — COMPONENT/COMPONENT_SET nodes or repeated patterns from analyze_clusters +- **Primitives** — leaf elements (text, icons, dividers) that don't need their own component file + +For each component determine: +- **Props** — what content varies between instances (text, color, icon, visibility) +- **Variants** — if the component has multiple states (default/hover/active, small/medium/large) +- **Slots** — where child content is injected + +### Step 3 — Extract tokens + +\`\`\` +list_variables → all variables with values per mode +list_collections → collection structure and mode names +\`\`\` + +Map design variables to code tokens. The approach depends on the target stack: + +#### Tailwind projects + +Do NOT create CSS custom properties for font sizes, font weights, spacing, or border radius — Tailwind has its own system for these. Use Tailwind utility classes directly: +- Font sizes → \`text-[13px]\`, \`text-sm\`, \`text-base\`, etc. +- Font weights → \`font-bold\`, \`font-medium\`, \`font-[600]\` +- Spacing → \`gap-3\`, \`p-4\`, \`px-5\`, \`py-[14px]\`, or arbitrary \`gap-[12px]\` +- Border radius → \`rounded-xl\`, \`rounded-[14px]\`, \`rounded-full\` + +Only create CSS custom properties for **semantic colors** — these are the values that would change across themes. Name them to avoid conflicts with Tailwind's built-in variables (do NOT use names like \`--font-bold\`, \`--text-sm\`, \`--radius-lg\`): + +\`\`\`css +:root { + --movie-bg: #0F0F1A; + --movie-surface: #1A1A2E; + --movie-accent: #7C3AED; + --movie-text: #FFFFFF; + --movie-text-dim: #FFFFFF80; +} +\`\`\` + +Reference in Tailwind classes: \`bg-[var(--movie-bg)]\`, \`text-[var(--movie-text)]\` + +#### CSS Modules / plain CSS projects + +Create CSS custom properties for all token categories (colors, spacing, typography, radius). Use a project-specific prefix to avoid collisions: + +\`\`\`css +:root { + --app-color-bg: #0F0F1A; + --app-space-sm: 4px; + --app-text-sm: 12px; + --app-radius-md: 8px; +} +\`\`\` + +#### No design variables in the file + +If the design has no Figma variables, extract implicit tokens from \`analyze_colors\` and \`analyze_typography\` output — identify the de facto palette and type scale. For Tailwind projects, only extract semantic colors as CSS custom properties; use Tailwind utilities for everything else. + +#### Multi-mode collections (light/dark) + +- Generate token values for each mode +- Use CSS custom properties with class-based switching (\`.dark { ... }\`) + +### Step 4 — Generate code + +For each component, bottom-up (primitives first, then composites, then screens): + +\`\`\` +get_jsx id= → read structure +describe id= → understand semantic role +export_svg ids=[] → extract vector assets +\`\`\` + +**Rules:** +- One component per file +- Component name comes from the Figma node name, converted to PascalCase +- Props interface reflects the variable content identified in Step 2 +- Use design tokens from Step 3 for semantic colors +- For Tailwind: use utility classes directly for spacing, font sizes, weights, radius — do NOT wrap them in \`var()\` indirection +- Match measurements exactly: font sizes, spacing, border radii, colors +- Use auto-layout data to determine flex direction, gap, padding, alignment +- Absolute positioning only when \`layoutPositioning\` is \`ABSOLUTE\` or layout mode is \`NONE\` +- If a node has \`clipsContent: true\`, use \`overflow: hidden\` +- Text nodes: preserve font family, size, weight, line height, letter spacing, alignment +- Images/illustrations: use \`export_svg\` for vectors, placeholder \`\` for raster + +**Interactive states:** + +Figma designs rarely include hover/active/focus states unless the component has explicit variants for them. Always add sensible interactive feedback to clickable elements: + +- **Buttons (primary):** \`hover:brightness-110 active:brightness-90 transition-all\` +- **Buttons (secondary/ghost):** \`hover:bg-white/[0.12] active:bg-white/[0.06] transition-colors\` +- **Icon buttons:** \`hover:bg-white/[0.15] active:scale-95 transition-all\` +- **Cards/list items (if clickable):** \`hover:bg-white/[0.04] transition-colors\` +- **Links/text buttons:** \`hover:underline\` or \`hover:opacity-80\` +- **All interactive elements:** add \`cursor-pointer\` and \`select-none\` +- **Focus visible:** add \`focus-visible:ring-2 focus-visible:ring-offset-2\` with accent color for accessibility + +If the design HAS explicit hover/active variants (COMPONENT_SET with state property), use those exact styles instead of defaults above. + +### Step 5 — Verify + +After generating code, verify against the design: + +\`\`\` +describe id= → re-check structure matches +get_jsx id= → compare JSX structure with generated component tree +\`\`\` + +Check: +- All text content from the design appears in the code +- All colors reference tokens or use correct hex/opacity values +- Spacing values match the design +- Component hierarchy matches the design's node tree +- No nodes were skipped or merged incorrectly + +List any deviations with rationale. + +## Target stack + +The user specifies the target stack. Adapt code generation accordingly: + +**React + Tailwind** — functional components, TypeScript, utility classes, \`className\` +**React + CSS Modules** — functional components, TypeScript, \`.module.css\` files, \`styles.className\` +**Vue 3 + Tailwind** — \`