课程 38 - 从设计到代码
变量与主题
Pencil 支持完整的 Design Token 系统,支持多主题条件取值,详见:Variables and Themes。变量系统可以有效减少硬编码,AI 不需要生成具体的颜色值(减少 #RRGGBB 格式错误),也不需要理解设计系统的 token 映射。它只需引用语义化变量名,渲染引擎负责解析。
api.setAppState({
variables: {
'color.background': { type: 'color', value: '#FFFFFF' },
'text.title': { type: 'number', value: 72 },
},
});
api.updateNodes([
{
id: 'r1',
type: 'rect',
x: 0,
y: 0,
width: 100,
height: 100,
zIndex: 1,
fill: '$color.background',
stroke: '$color.background',
},
]);AI 为 dark mode 生成设计时,不需要输出两套颜色方案,只需引用 $color.bg,由节点的 theme 属性决定实际取值。
"variables": {
"color.bg": {
"type": "color",
"value": [
{ "value": "#FFFFFF", "theme": { "mode": "light" } },
{ "value": "#000000", "theme": { "mode": "dark" } }
]
}
}另外 AI 也不需要计算像素值或处理响应式断点。它用声明式语义描述意图,布局引擎自动计算几何。详见 课程 33 - 布局引擎
解析
Figma 支持以下四种类型的变量,详见:Guide to variables in Figma
- Color
#000000 - Number
- String 例如
fontFamily或者文本内容 - Boolean
属性面板与变量选择器
选中节点后,在 Spectrum 属性面板中可以为填充 / 描边颜色 / 线宽 / 字号绑定 AppState.variables 里的设计变量:通过下拉选择变量名即可写入 $token;已绑定时会显示紫色徽标,并可一键解除绑定(写回当前解析后的字面量并记入历史)。取色器与线宽滑块始终按解析后的值展示,避免 $... 直接当作 CSS 颜色无效。
导出 SVG
我们可以有多种导出策略,默认使用解析后的字面量:
export type DesignVariablesSvgExportMode =
/** 解析 $ → 字面量 */
| 'resolved'
/** 保留 `$token` 字符串(属性可能非标准,适合再加工) */
| 'preserve-token'
/** `:root{--x:...}` + `fill="var(--x)"` 形式 */
| 'css-var';也可以使用 CSS variables 导出策略,会在 :root 中声明这些全局变量,便于在浏览器开发者工具中修改。详见:Using CSS custom properties (variables)
<svg>
<defs>
<style>
:root {
--color-background: #2563eb;
--color-stroke: red;
--text-title: 72px;
}
</style>
</defs>
<rect
fill="var(--color-background)"
stroke="var(--color-stroke)"
stroke-width="2"
width="100"
height="100"
id="node-r1"
/>
</svg>icon
icon 在生成 UI 时非常重要,例如 Lucide 已经在 React 组件生成中大规模使用了。Pencil 也支持这种内置图形:
export interface IconFont extends Entity, Size, CanHaveEffects {
type: 'icon_font';
/** Name of the icon in the icon font */
iconFontName?: StringOrVariable;
/** Icon font to use. Valid fonts are 'lucide', 'feather', 'Material Symbols Outlined', 'Material Symbols Rounded', 'Material Symbols Sharp', 'phosphor' */
iconFontFamily?: StringOrVariable;
/** Variable font weight, only valid for icon fonts with variable weight. Values from 100 to 700. */
weight?: NumberOrVariable;
fill?: Fills;
}OpenPencil 的 iconLookup 是可注入的函数,这意味着:
- 灵活性:可以接入任何图标源(Iconify、Lucide、自定义)
- AI 负担:AI 只需要输出 iconFontName: "SearchIcon",具体路径由运行时解析
private drawIconFont(canvas, node, x, y, w, h, opacity) {
const iconName = iNode.iconFontName ?? iNode.name ?? '';
const iconMatch = this.iconLookup?.(iconName) ?? null;
const iconD = iconMatch?.d ?? FALLBACK_ICON_D; // SVG path data
const iconStyle = iconMatch?.style ?? 'stroke'; // stroke or fill
// 解析 SVG path → Skia Path → 缩放适配 → 绘制
}我们的定义如下:
export interface IconFontAttributes {
/** 图标在字体族中的名称 */
iconFontName?: StringOrVariable;
/**
* 字体族。例如:'lucide'、'feather'、'Material Symbols Outlined'、'phosphor' 等。
*/
iconFontFamily?: StringOrVariable;
}
export interface IconFontSerializedNode
extends BaseSerializeNode<'iconfont'>,
Partial<IconFontAttributes>;动态注册 icon 信息
我们使用 IconifyJSON 提供的 icon 类型,可以在运行时动态引入 Lucide、Material 等图标库,它提供了包含图标 SVG 的 JSON:
import { registerIconifyIconSet } from '@infinite-canvas-tutorial/ecs';
const m = await import('@iconify/json/json/lucide.json');
registerIconifyIconSet('lucide', m);然后我们就可以将图标 JSON 转换成我们的场景图表示,例如下面的 Search 图标会被解析成一个 Group 父节点,拥有一个 Path 和 Circle 子节点,这部分和之前将 SVG 元素转换成我们的图形表示几乎一模一样:
"search": {
"body": "<g fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"><path d=\"m21 21l-4.34-4.34\"/><circle cx=\"11\" cy=\"11\" r=\"8\"/></g>"
}当然我们需要将宽高、strokeWidth 映射到转换后的场景图上:
function buildIconFontScalablePrimitives(
iconFontName: string,
iconFontFamily: string,
targetWidth: number,
targetHeight: number,
): ScaledIconPrimitive[] {}在图层列表中展示
Iconify 也提供了开箱即用的 Webcomponents 组件用于展示,详见:Iconify Icon web component。这样我们就可以在图层列表项目的缩略图中展示了:
import 'iconify-icon';
if (this.node.type === 'iconfont') {
const iconName = this.#normalizeIconifyName(
this.node as IconFontSerializedNode,
);
thumbnail = iconName
? html`<iconify-icon icon=${iconName}></iconify-icon>`
: html`<sp-icon-group></sp-icon-group>`;
}导出 SVG
结合布局渲染组件
结合 课程 33 - 布局引擎 我们就可以实现带有 icon 的 Button 了:
const button1 = {
id: 'icon-button',
type: 'rect',
fill: 'grey',
display: 'flex',
width: 200,
height: 100,
padding: 10,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
cornerRadius: 10,
gap: 10,
} as const;
const searchIcon = {
id: 'icon-button-search',
parentId: 'icon-button',
type: 'iconfont',
iconFontName: 'search',
iconFontFamily: 'lucide',
};
const text = {
id: 'icon-button-text',
parentId: 'icon-button',
type: 'text',
content: 'Button',
};我们想像 Shadcn UI 一样支持不同变体的 Button 组件,可以减少大量样板代码:
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>组件化生成
.pen 的 ref + descendants 系统本质上是一种面向 AI 的组件继承机制。这样 AI 就不需要理解"圆角矩形 + 文本 + 内边距"的底层构成。它只需引用设计系统已有的 round-button 组件,并覆盖文本内容。这类似于代码中的继承+覆盖。详见:Components and Instances
{
"id": "round-button",
"type": "g",
"reusable": true,
"cornerRadius": 9999,
"children": [
{
"id": "label",
"type": "text",
"content": "Submit",
"fill": "#000000"
...
}
]
}
{
"id": "save-round-button",
"type": "ref",
"ref": "round-button",
"descendants": { "label": { "content": "Save" } }
}Design ↔ Code
| 产品 | 核心策略 | 工程实现 |
|---|---|---|
| OpenPencil | 规则引擎 + 增量管道 | pen-codegen 包提供确定性转换,codegen_plan/submit/assemble/clean 处理大文件 |
| Pencil.dev | AI 自由生成 + 双向同步 | .pen 文件作为上下文,AI 直接输出代码,支持 Code → Design 反向导入 |