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:
Anton A S 2026-03-13 14:46:18 +03:00
parent 68b5cfcea1
commit d688d04bef
20 changed files with 513 additions and 706 deletions

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ packages/collab/.wrangler/
packages/core/vendor/canvaskit-webgpu/
patches/skia-webgpu/
.wrangler/
Caddyfile.dev
components.d.ts

View file

@ -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
}
}

View file

@ -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
View file

@ -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']
}
}

View file

@ -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",

View 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
}

View file

@ -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

View file

@ -243,3 +243,5 @@ export {
parseVarint,
FIG_WIRE_MAGIC
} from './kiwi'
export { CODEGEN_PROMPT } from './tools/prompts/codegen-prompt'

View file

@ -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> {

View file

@ -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)}` }
}

View file

@ -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 })
}

View file

@ -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

View file

@ -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 {

View 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
}

View file

@ -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[] = []

View file

@ -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'],

View 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\`
`

View file

@ -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

View file

@ -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
})
},

View file

@ -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)