Address PR #94 review: dedup, replace hand-rolled code, cleanup
- 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
This commit is contained in:
parent
68b5cfcea1
commit
d688d04bef
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -38,3 +38,6 @@ packages/collab/.wrangler/
|
|||
packages/core/vendor/canvaskit-webgpu/
|
||||
patches/skia-webgpu/
|
||||
.wrangler/
|
||||
|
||||
Caddyfile.dev
|
||||
components.d.ts
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
4
bun.lock
4
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=="],
|
||||
|
|
|
|||
120
components.d.ts
vendored
120
components.d.ts
vendored
|
|
@ -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']
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
67
packages/core/src/icon-render.ts
Normal file
67
packages/core/src/icon-render.ts
Normal file
|
|
@ -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<string, SceneNode['strokeCap']> = {
|
||||
butt: 'NONE', round: 'ROUND', square: 'SQUARE'
|
||||
}
|
||||
|
||||
const STROKE_JOIN_MAP: Record<string, SceneNode['strokeJoin']> = {
|
||||
miter: 'MITER', round: 'ROUND', bevel: 'BEVEL'
|
||||
}
|
||||
|
||||
export function createIconFromPaths(
|
||||
graph: SceneGraph,
|
||||
icon: IconData,
|
||||
name: string,
|
||||
size: number,
|
||||
color: Color,
|
||||
parentId: string,
|
||||
overrides?: Partial<SceneNode>
|
||||
): 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
|
||||
}
|
||||
|
|
@ -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, (tag: string) => 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\b[^>]*>/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\b[^>]*>/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<Response> {
|
||||
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<Response> {
|
||||
return fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
||||
}
|
||||
|
||||
export async function fetchIcon(name: string, size = 24): Promise<IconData> {
|
||||
const results = await fetchIcons([name], size)
|
||||
const result = results.get(name)
|
||||
|
|
@ -274,10 +203,6 @@ export async function fetchIcon(name: string, size = 24): Promise<IconData> {
|
|||
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<Map<string, IconData>> {
|
||||
const results = new Map<string, IconData>()
|
||||
const toFetch = new Map<string, string[]>()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -243,3 +243,5 @@ export {
|
|||
parseVarint,
|
||||
FIG_WIRE_MAGIC
|
||||
} from './kiwi'
|
||||
|
||||
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'
|
||||
|
|
|
|||
|
|
@ -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<Record<string, NodeType>> = {
|
||||
|
|
@ -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<string, unknown>
|
||||
): SceneNode {
|
||||
const parent = graph.getNode(parentId)
|
||||
const parentLayout = parent?.layoutMode ?? 'NONE'
|
||||
const overrides: Partial<SceneNode> = {
|
||||
name: (props.label as string | undefined) ?? `Icon / ${iconName}`,
|
||||
width: size,
|
||||
height: size,
|
||||
fills: []
|
||||
}
|
||||
const overrides: Partial<SceneNode> = {}
|
||||
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<string, SceneNode['strokeCap']> = {
|
||||
round: 'ROUND', square: 'SQUARE', butt: 'NONE'
|
||||
}
|
||||
|
||||
const ICON_STROKE_JOIN: Record<string, SceneNode['strokeJoin']> = {
|
||||
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<SceneNode> {
|
||||
|
|
|
|||
|
|
@ -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<string, (...args: number[]) => 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)}` }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, StrokeCap> = {
|
||||
butt: 'NONE',
|
||||
round: 'ROUND',
|
||||
square: 'SQUARE'
|
||||
}
|
||||
|
||||
const STROKE_JOIN_MAP: Record<string, StrokeJoin> = {
|
||||
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<string, unknown>)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
27
packages/core/src/tools/describe-shared.ts
Normal file
27
packages/core/src/tools/describe-shared.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
234
packages/core/src/tools/prompts/codegen-prompt.ts
Normal file
234
packages/core/src/tools/prompts/codegen-prompt.ts
Normal file
|
|
@ -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=<component_id> → read structure
|
||||
describe id=<component_id> → understand semantic role
|
||||
export_svg ids=[<icon_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 \`<img>\` 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=<root> → re-check structure matches
|
||||
get_jsx id=<root> → 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** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, Tailwind utility classes
|
||||
**Vue 3 + CSS** — \`<script setup lang="ts">\`, \`defineProps\`, \`<template>\`, scoped \`<style>\`
|
||||
**Svelte + Tailwind** — \`<script lang="ts">\`, \`$props()\`, Tailwind utility classes
|
||||
**HTML + CSS** — semantic HTML, BEM or utility classes, CSS custom properties
|
||||
|
||||
If the user hasn't specified a stack, ask before generating code.
|
||||
|
||||
## Component file structure
|
||||
|
||||
\`\`\`
|
||||
components/
|
||||
Button.tsx (or .vue, .svelte)
|
||||
Card.tsx
|
||||
Header.tsx
|
||||
...
|
||||
tokens.css (semantic color tokens only, for Tailwind projects)
|
||||
pages/
|
||||
HomePage.tsx (or routes, views — depends on framework)
|
||||
assets/
|
||||
icon-arrow.svg
|
||||
icon-check.svg
|
||||
\`\`\`
|
||||
|
||||
## Common patterns
|
||||
|
||||
**Auto-layout → Flexbox**
|
||||
- \`layoutMode: HORIZONTAL\` → \`flex-direction: row\`
|
||||
- \`layoutMode: VERTICAL\` → \`flex-direction: column\`
|
||||
- \`itemSpacing\` → \`gap\`
|
||||
- \`paddingTop/Right/Bottom/Left\` → \`padding\`
|
||||
- \`primaryAxisAlign: CENTER\` → \`justify-content: center\`
|
||||
- \`counterAxisAlign: CENTER\` → \`align-items: center\`
|
||||
- \`layoutWrap: WRAP\` → \`flex-wrap: wrap\`
|
||||
- \`primaryAxisSizing: HUG\` → no explicit size on primary axis (content-sized)
|
||||
- \`primaryAxisSizing: FILL\` → \`flex: 1\` or \`width: 100%\` depending on context
|
||||
- \`counterAxisSizing: FILL\` → \`align-self: stretch\` or explicit \`width/height: 100%\`
|
||||
|
||||
**Grid layout**
|
||||
- \`layoutMode: GRID\` → \`display: grid\`
|
||||
- \`gridTemplateColumns\` → \`grid-template-columns\`
|
||||
- \`gridTemplateRows\` → \`grid-template-rows\`
|
||||
- \`gridColumnGap/gridRowGap\` → \`column-gap/row-gap\`
|
||||
|
||||
**Sizing**
|
||||
- \`layoutGrow > 0\` → \`flex-grow: 1\`
|
||||
- \`layoutAlignSelf: STRETCH\` → cross-axis fill
|
||||
- Fixed width/height only when sizing mode is \`FIXED\`
|
||||
|
||||
**Corner radius**
|
||||
- \`independentCorners: true\` → per-corner border-radius
|
||||
- \`cornerRadius\` → uniform border-radius
|
||||
|
||||
**Effects**
|
||||
- \`DROP_SHADOW\` → \`box-shadow\`
|
||||
- \`INNER_SHADOW\` → \`box-shadow: inset ...\`
|
||||
- \`LAYER_BLUR\` → \`filter: blur(...)\`
|
||||
- \`BACKGROUND_BLUR\` → \`backdrop-filter: blur(...)\`
|
||||
|
||||
**Text**
|
||||
- \`fontFamily\` → \`font-family\`
|
||||
- \`fontSize\` → \`font-size\`
|
||||
- \`fontWeight\` → \`font-weight\`
|
||||
- \`lineHeight\` → \`line-height\` (null = normal/auto)
|
||||
- \`letterSpacing\` → \`letter-spacing\`
|
||||
- \`textAlignHorizontal\` → \`text-align\`
|
||||
- \`textAutoResize: WIDTH_AND_HEIGHT\` → no explicit dimensions
|
||||
- \`textAutoResize: HEIGHT\` → fixed width, auto height
|
||||
- \`textAutoResize: NONE\` → fixed width and height
|
||||
- \`textDecoration\` → \`text-decoration\`
|
||||
- \`textCase\` → \`text-transform\`
|
||||
`
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isAbsolute, relative, resolve } from 'node:path'
|
||||
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
ALL_TOOLS,
|
||||
CODEGEN_PROMPT,
|
||||
FigmaAPI,
|
||||
parseFigFile,
|
||||
computeAllLayouts,
|
||||
|
|
@ -195,24 +195,13 @@ export function createServer(version: string, options: CreateServerOptions = {})
|
|||
registerTool(tool)
|
||||
}
|
||||
|
||||
let _codegenPrompt: string | null = null
|
||||
register(
|
||||
'get_codegen_prompt',
|
||||
{
|
||||
description: 'Get design-to-code generation guidelines. Call before generating frontend code.',
|
||||
inputSchema: z.object({})
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
if (!_codegenPrompt) {
|
||||
const corePath = dirname(fileURLToPath(import.meta.resolve('@open-pencil/core')))
|
||||
_codegenPrompt = await readFile(join(corePath, 'tools', 'prompts', 'codegen.md'), 'utf-8')
|
||||
}
|
||||
return ok({ prompt: _codegenPrompt })
|
||||
} catch (e) {
|
||||
return fail(e)
|
||||
}
|
||||
}
|
||||
async () => ok({ prompt: CODEGEN_PROMPT })
|
||||
)
|
||||
|
||||
return server
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ import type { SceneNode, StepBudget, ToolLogEntry } from '@open-pencil/core'
|
|||
|
||||
export const MAX_AGENT_STEPS = 50
|
||||
|
||||
let _toolLogEntries: ToolLogEntry[] = []
|
||||
let _currentRunSteps = 0
|
||||
|
||||
export interface StepUsage {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
|
|
@ -21,32 +18,54 @@ export interface StepUsage {
|
|||
timestamp: number
|
||||
}
|
||||
|
||||
let _stepUsages: StepUsage[] = []
|
||||
class RunState {
|
||||
toolLog: ToolLogEntry[] = []
|
||||
stepUsages: StepUsage[] = []
|
||||
currentSteps = 0
|
||||
|
||||
recordStep(usage: StepUsage): void {
|
||||
this.stepUsages.push(usage)
|
||||
this.currentSteps++
|
||||
}
|
||||
|
||||
resetSteps(): void {
|
||||
this.currentSteps = 0
|
||||
}
|
||||
|
||||
hitLimit(): boolean {
|
||||
return this.currentSteps >= MAX_AGENT_STEPS
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.toolLog = []
|
||||
this.stepUsages = []
|
||||
}
|
||||
}
|
||||
|
||||
export const runState = new RunState()
|
||||
|
||||
export function getToolLogEntries(): ToolLogEntry[] {
|
||||
return _toolLogEntries
|
||||
return runState.toolLog
|
||||
}
|
||||
|
||||
export function getStepUsages(): StepUsage[] {
|
||||
return _stepUsages
|
||||
return runState.stepUsages
|
||||
}
|
||||
|
||||
export function recordStepUsage(usage: StepUsage): void {
|
||||
_stepUsages.push(usage)
|
||||
_currentRunSteps++
|
||||
runState.recordStep(usage)
|
||||
}
|
||||
|
||||
export function resetRunSteps(): void {
|
||||
_currentRunSteps = 0
|
||||
runState.resetSteps()
|
||||
}
|
||||
|
||||
export function didHitStepLimit(): boolean {
|
||||
return _currentRunSteps >= MAX_AGENT_STEPS
|
||||
return runState.hitLimit()
|
||||
}
|
||||
|
||||
export function clearToolLogEntries(): void {
|
||||
_toolLogEntries = []
|
||||
_stepUsages = []
|
||||
runState.clear()
|
||||
}
|
||||
|
||||
export function createAITools(store: EditorStore) {
|
||||
|
|
@ -81,10 +100,10 @@ export function createAITools(store: EditorStore) {
|
|||
store.flashNodes(nodeIds)
|
||||
},
|
||||
onToolLog: (entry) => {
|
||||
_toolLogEntries.push(entry)
|
||||
runState.toolLog.push(entry)
|
||||
},
|
||||
getStepBudget: (): StepBudget => ({
|
||||
current: _currentRunSteps,
|
||||
current: runState.currentSteps,
|
||||
max: MAX_AGENT_STEPS
|
||||
})
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,22 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
import { describe, expect, test, beforeAll } from 'bun:test'
|
||||
|
||||
import { fetchIcon, fetchIcons, searchIcons, searchIconsBatch } from '@open-pencil/core'
|
||||
|
||||
let hasNetwork = true
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await fetch('https://api.iconify.design/mdi.json?icons=home', { signal: AbortSignal.timeout(3000) })
|
||||
} catch {
|
||||
hasNetwork = false
|
||||
console.warn('⚠ Skipping iconify tests — no network')
|
||||
}
|
||||
})
|
||||
|
||||
const networkTest: typeof test = (name, fn, timeout) =>
|
||||
test(name, async () => { if (!hasNetwork) return; await (fn as () => Promise<void>)() }, timeout)
|
||||
|
||||
describe('fetchIcon', () => {
|
||||
test('fetches mdi:home', async () => {
|
||||
networkTest('fetches mdi:home', async () => {
|
||||
const icon = await fetchIcon('mdi:home', 24)
|
||||
expect(icon.prefix).toBe('mdi')
|
||||
expect(icon.name).toBe('home')
|
||||
|
|
@ -18,7 +31,7 @@ describe('fetchIcon', () => {
|
|||
expect(path.stroke).toBeNull()
|
||||
})
|
||||
|
||||
test('fetches lucide:heart (stroked icon)', async () => {
|
||||
networkTest('fetches lucide:heart (stroked icon)', async () => {
|
||||
const icon = await fetchIcon('lucide:heart', 24)
|
||||
expect(icon.paths.length).toBeGreaterThan(0)
|
||||
|
||||
|
|
@ -30,34 +43,34 @@ describe('fetchIcon', () => {
|
|||
expect(path.strokeJoin).toBe('round')
|
||||
})
|
||||
|
||||
test('scales to custom size', async () => {
|
||||
networkTest('scales to custom size', async () => {
|
||||
const icon = await fetchIcon('mdi:home', 48)
|
||||
expect(icon.width).toBe(48)
|
||||
expect(icon.height).toBe(48)
|
||||
})
|
||||
|
||||
test('caches repeated fetches', async () => {
|
||||
networkTest('caches repeated fetches', async () => {
|
||||
const a = await fetchIcon('mdi:home', 24)
|
||||
const b = await fetchIcon('mdi:home', 24)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
test('throws on invalid icon name format', async () => {
|
||||
networkTest('throws on invalid icon name format', async () => {
|
||||
await expect(fetchIcon('invalid')).rejects.toThrow('prefix:name')
|
||||
})
|
||||
|
||||
test('throws on non-existent icon', async () => {
|
||||
networkTest('throws on non-existent icon', async () => {
|
||||
await expect(fetchIcon('mdi:this-icon-definitely-does-not-exist-xyz123')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
test('heroicons:eye — multi-path icon with group attrs', async () => {
|
||||
networkTest('heroicons:eye — multi-path icon with group attrs', async () => {
|
||||
const icon = await fetchIcon('heroicons:eye', 24)
|
||||
expect(icon.paths.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fetchIcons (batch)', () => {
|
||||
test('fetches multiple icons from same prefix in one request', async () => {
|
||||
networkTest('fetches multiple icons from same prefix in one request', async () => {
|
||||
const icons = await fetchIcons(['lucide:star', 'lucide:home', 'lucide:search'], 24)
|
||||
expect(icons.size).toBe(3)
|
||||
expect(icons.get('lucide:star')).toBeDefined()
|
||||
|
|
@ -65,20 +78,20 @@ describe('fetchIcons (batch)', () => {
|
|||
expect(icons.get('lucide:search')).toBeDefined()
|
||||
})
|
||||
|
||||
test('fetches icons across multiple prefixes', async () => {
|
||||
networkTest('fetches icons across multiple prefixes', async () => {
|
||||
const icons = await fetchIcons(['mdi:heart', 'lucide:heart'], 24)
|
||||
expect(icons.size).toBe(2)
|
||||
expect(icons.get('mdi:heart')?.prefix).toBe('mdi')
|
||||
expect(icons.get('lucide:heart')?.prefix).toBe('lucide')
|
||||
})
|
||||
|
||||
test('skips non-existent icons without failing', async () => {
|
||||
networkTest('skips non-existent icons without failing', async () => {
|
||||
const icons = await fetchIcons(['mdi:home', 'mdi:nonexistent-xyz-999'], 24)
|
||||
expect(icons.has('mdi:home')).toBe(true)
|
||||
expect(icons.has('mdi:nonexistent-xyz-999')).toBe(false)
|
||||
})
|
||||
|
||||
test('populates cache for subsequent fetchIcon calls', async () => {
|
||||
networkTest('populates cache for subsequent fetchIcon calls', async () => {
|
||||
await fetchIcons(['mdi:star'], 24)
|
||||
const icon = await fetchIcon('mdi:star', 24)
|
||||
expect(icon.prefix).toBe('mdi')
|
||||
|
|
@ -86,14 +99,14 @@ describe('fetchIcons (batch)', () => {
|
|||
})
|
||||
|
||||
describe('searchIcons', () => {
|
||||
test('searches for heart icons', async () => {
|
||||
networkTest('searches for heart icons', async () => {
|
||||
const result = await searchIcons('heart', { limit: 10 })
|
||||
expect(result.icons.length).toBeGreaterThan(0)
|
||||
expect(result.total).toBeGreaterThan(0)
|
||||
expect(result.icons[0]).toContain(':')
|
||||
})
|
||||
|
||||
test('filters by prefix', async () => {
|
||||
networkTest('filters by prefix', async () => {
|
||||
const result = await searchIcons('home', { limit: 5, prefix: 'lucide' })
|
||||
for (const icon of result.icons) {
|
||||
expect(icon.startsWith('lucide:')).toBe(true)
|
||||
|
|
@ -102,7 +115,7 @@ describe('searchIcons', () => {
|
|||
})
|
||||
|
||||
describe('searchIconsBatch', () => {
|
||||
test('searches multiple queries in parallel', async () => {
|
||||
networkTest('searches multiple queries in parallel', async () => {
|
||||
const results = await searchIconsBatch(['heart', 'arrow'], { limit: 5 })
|
||||
expect(results.size).toBe(2)
|
||||
expect(results.get('heart')!.icons.length).toBeGreaterThan(0)
|
||||
|
|
|
|||
Loading…
Reference in a new issue