课程 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);图标正文支持单引号或双引号的 SVG 属性。路径、矩形、圆、椭圆和线段既可以使用 自闭合标签,也可以使用显式闭合标签;嵌套 <g> 的填充和描边属性会传给子图形。
然后我们就可以将图标 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 反向导入 |
Design ↔ Code 的核心洞察是:design-to-code 不是「像素 → 代码」的 AI 猜测。由于 .ic 格式已经内置了对代码友好的原语——flex 布局、设计变量、reusable/ref 组件、语义命名、图标字体——场景图本身就是一份为代码而设计的 IR。因此核心是一个确定性转译器,AI 仅在命名/结构清理这一层锦上添花。
管线
我们仿照 SVG 导出管线,但额外插入一层框架无关的 Code IR,这样新增目标框架只需再写一个 emitter:
.ic SceneGraph
→ expandRefSerializedNodes // 复用现有的 ref/reusable 处理
→ CodeIR // 元素树 + 结构化样式 + 变量引用 + 组件定义
→ Emitter // react-tailwind | html-css | ...Code IR 节点保留:角色(容器 / 文本 / 图标 / 图片 / 形状)、解析前的 $token 引用、结构化的 flex 样式,以及 reusable/ref 的组件关系。
入口
API.exportCode 与 renderToSVG 对称:不传参时转译整个场景,传入节点则转译选区。
const code = api.exportCode(undefined, {
framework: 'react-tailwind', // 或 'html-css'
variablesMode: 'css-var', // 'resolved' | 'preserve-token' | 'css-var'
componentStructure: 'preserve', // 'preserve' | 'flatten'
});也可以直接调用纯函数:
import { serializedNodesToCode } from '@infinite-canvas-tutorial/ecs';
const code = serializedNodesToCode(nodes, {
framework: 'react-tailwind',
variables,
});概念映射
.ic 概念 | 代码产物 |
|---|---|
rect/g + display: flex | <div> + flex 工具类(flex items-center …) |
| Flex 方向、对齐、尺寸、间距和内边距 | Tailwind flex 工具类 / CSS |
cornerRadius / fills / strokes / dropShadow | rounded-* / bg-* / border / shadow-* |
带图片填充的 rect | <img src> 与 object-fit / object-position |
单层 CSS 渐变填充的 rect | CSS background-image |
ellipse | CSS border-radius: 50% |
带纯色填充与描边的叶子 path | 保留矢量轮廓的内联 SVG <path> |
带纯色描边的叶子 line / polyline | 保留开放线段的内联 SVG <path> |
rect / ellipse + clipMode: clip | 在裁剪元素上使用 CSS overflow: hidden |
rotation / scaleX / scaleY | 围绕局部左上角的 CSS transform |
节点级 blendMode | CSS mix-blend-mode |
visibility: hidden / visible | CSS 可见性,保留布局空间 |
text + content | 文本节点;已定义的 $token → 当前文本值 |
| 文本装饰的线条、样式、颜色与粗细 | CSS text-decoration |
fontVariant / fontKerning | CSS font-variant / font-kerning |
wordWrap / wordWrapWidth | white-space、overflow-wrap 与 max-width |
maxLines / textOverflow | CSS 多行省略(ellipsis)或按行高裁剪(clip) |
iconfont(lucide) | lucide-react <Search /> |
iconfont(其他族) | @iconify/react <Icon icon="family:name" /> |
$color.bg 变量 | bg-[var(--color-bg)](css-var)/ 字面量(resolved) |
reusable 根 | 一个 React 组件定义 |
ref + descendants 覆盖 | 组件实例 + props |
name | 组件名 / prop 名 / class 名 |
变量模式
三种变量模式与 SVG 导出语义保持一致:
resolved(默认):把$token解析为字面量(如bg-[#FFFFFF])。css-var:样式输出var(--token)(如bg-[var(--color-bg)])。HTML/CSS 导出包含:root { … }块;React 为每个导出组件限定变量作用域,单独渲染可复用组件时也有效。嵌套实例共用父组件的作用域,因此多个导出组件可以对同一个 token 使用不同的值。CSS 自定义属性不能替换文本节点,所以文本内容变量会解析为当前值。preserve-token:保留$token(写入 inlinestyle,便于再加工)。
组件与实例
在 componentStructure: 'preserve'(默认,也是 Pencil 的卖点)下,每个 reusable 根会成为一个组件,每个 ref 成为一次实例调用。各实例上出现过的可覆盖属性(content、fills、包括 fontVariant 和 fontKerning 的排版属性、文本装饰、opacity、visibility、cornerRadius、显式 width / height、minWidth / maxWidth / minHeight / maxHeight、padding、margin、flex 间距、flexDirection、alignItems、justifyContent、flexWrap、alignSelf、flexGrow、flexShrink、flexBasis、strokeWidth、strokes 和投影字段)会被提升为 props,默认值取自模板。.ic 中数值型 lineHeight 表示像素高度,因此 React 导出会补 px,不会将其当成无单位的行高倍率。布尔型 fontKerning 会转为 CSS 的 normal 或 none。四边间距数组会转为 CSS 简写字符串,文本装饰字段会合并为一个参数,投影字段会合并为一个 boxShadow prop。装饰线粗细为 0 时导出 none,与画布语义一致:
未显式设置 lineHeight 时,文本导出使用无单位的 1.2 行高,使浏览器的 flex 间距接近画布的文本测量结果;显式设置的数值行高仍以像素导出。white-space: pre 会保留显式换行。开启 wordWrap 且 wordWrapWidth 为正数时,会使用 pre-wrap、overflow-wrap: anywhere,并把该宽度作为最大宽度。正数 maxLines 会为默认的 ellipsis 添加 CSS 多行省略,为 clip 添加按行高裁剪。已开启换行的可复用文本如果只修改换行宽度,会生成组件 prop;切换换行开关或修改截断配置则会展开实例,以匹配编辑后的布局。当前画布测量尚未应用 whiteSpace 属性;自定义 textOverflow 标记字符串尚无对应的标准 CSS 实现。
对于可复用图片,仅修改图片 URL 时,实例的 fills 覆盖会绑定到组件的 <img src> 参数。修改填充类型、适配方式、位置或填充透明度时,实例会展开,使生成的元素与样式对应编辑结果。没有子节点的单层图片会在 <img> 上合并填充透明度与节点整体透明度。单层图片填充的椭圆会导出为圆角 <img>。带子节点的图片填充矩形和椭圆会使用定位容器,将装饰性 <img> 放在子节点后方;容器承担节点整体透明度,图片承担填充透明度、适配方式和位置。
矩形只有一层启用的 CSS 线性、径向或锥形渐变填充时,会导出为 CSS background-image。实例仅修改渐变值时会生成组件参数。矩形和椭圆的多层纯色、受支持的渐变、图片或图案填充会按绘制顺序导出为叠加的背景;单层图案也会导出为 CSS 背景。画布的 conic-gradient(circle at …) 语法会转换为 CSS 的 conic-gradient(at …)。图片层支持 fill、contain、cover、none、scale-down 和定位,并让子元素处于涂料上方。图案会保留原始平铺尺寸及重复模式。需要独立透明度、scale-down 或受支持 2D 图案变换时,代码生成器会在子元素下方创建独立裁剪的装饰层;有 CSS 对应模式的图层混合仍限定在填充栈内。尺寸明确的矩形和椭圆支持图案的 matrix、translate、scale、rotate、skewX 和 skewY 变换;画布也会解码 URL 图案并应用变换。可复用实例修改填充栈或图案时会展开。其他渐变格式、不支持的图案变换语法,以及 linearBurn、linearDodge 等没有 CSS 对应模式的混合效果仍需单独处理。
单层纯色填充的透明度会通过 CSS color-mix 导出,不会连同子元素一起变淡。节点整体透明度仍使用独立的 CSS opacity。可复用实例修改半透明填充时会展开,以保留生成样式中的填充透明度。
节点旋转(弧度)与 scaleX / scaleY 会导出为以 0 0 为原点的 CSS rotate(...) scale(...),顺序与画布局部变换一致。实例修改变换后会展开,以包含修改后的几何效果。
非 flex 容器中的子节点会把画布局部 x / y 导出为相对于容器的 CSS left / top;flex 子节点仍按正常 flex 流布局。Yoga 默认的 flexShrink 为 0,因此节点未指定其他值时,导出会设置 flex-shrink: 0。非 flex 容器里的复用组件实例会增加定位包装元素,修改实例内部子节点的位置时则展开实例以保留编辑。多个顶层节点都有明确尺寸时,导出代码还会创建相对定位的外框,保留它们之间的间距,包括旋转和翻转后的边界。尺寸无法测量的顶层节点仍按普通文档流排列。
节点级 visibility 会导出为 CSS visibility,隐藏节点仍占据布局空间。默认的 inherited 状态对应 CSS 继承。复用组件实例及其子节点修改可见性时,会生成组件参数。
有有效 d 数据的 Path 会导出为内联 SVG。Path 带子节点时,SVG 会放在定位容器的底层,同时保留子节点的局部坐标和矢量轮廓。由纯色、线性或径向渐变、图片构成的多层填充,会按画布顺序导出为独立 SVG path,并保留各层透明度。具有 CSS 对应模式的混合效果会应用在隔离的 SVG 分组中,仅与下方填充层混合。上述涂料类型的多层描边会保留各层涂料、透明度、线形/三角形/菱形端点标记、填充规则、宽度、端帽、连接、斜接限制和虚线参数。Path、Line 和 Polyline 会共用描边几何,按画布顺序逐层导出,并支持有 CSS 对应模式的描边层混合。闭合 Path 的内侧或外侧对齐会对每一层应用裁剪或遮罩;开放路径仍按居中描边导出。渐变和图片涂料会跟随 Path 视口。Path 图片填充支持 fill、contain、cover、none、scale-down,也支持中间百分比定位。常见的 fill/contain/cover 对齐使用 SVG 图案;其他适配和定位会导出裁剪的 SVG foreignObject,并由 CSS object-fit/object-position 控制。图片描边在 fill、contain、cover 且各轴定位为 0%、50%、100% 时仍使用 SVG 图案。单层图片描边采用其他适配方式或定位时,会通过定位的 HTML 图片和 SVG 几何遮罩导出,在 Chromium 和 WebKit 中保留端帽、连接、端点标记及内外侧对齐。采用这些适配方式或定位的多层图片描边也会与 SVG 描边层一起保留画布绘制顺序、受 CSS 支持的混合模式、透明度和端点标记。导出代码中的图片应使用持久 URL 或 data URL。投影会跟随 SVG 图形轮廓,不会给 Path 的子节点重复加阴影。复用实例修改 Path 的填描、几何、标记、描边对齐或投影时会展开实例,确保 SVG 对应编辑结果。linearBurn 与 linearDodge 等没有 CSS 对应模式的混合效果、锥形与网格渐变及其他滤镜仍需单独实现。
没有子节点的 Line 与 Polyline 复用同一套 SVG 导出:局部端点或点列表会转换为无填充的开放路径。SVG 视口会跟随几何的局部原点,并让零宽或零高的轴居中,因此偏移的 Path、细水平线和竖线也能正常显示。描边支持单层启用的纯色、线性渐变、径向渐变或图片涂料。线形、三角形和菱形箭头会使用局部 SVG 标记,并跟随描边涂料。复用实例修改线段几何、描边或标记时也会展开。其他线条效果仍需单独实现。
矩形和椭圆的 clipMode: clip 会在导出的 HTML 和 React 代码中裁剪子内容。圆角矩形保留圆角,椭圆使用 border-radius: 50%。复用实例修改裁剪模式时会展开实例以保留该修改。erase 和 soft 蒙版无法直接映射为 CSS overflow,暂不作为裁剪导出。
节点级 blendMode 会导出为 CSS mix-blend-mode;实例修改它时也会生成组件参数。light 对应 CSS 的 lighten;没有 CSS 等价物的模式与 SVG 导出一致,按 normal 处理。填充层自身的 blendMode 用于该节点内部的多层填充合成,不会作为节点级 mix-blend-mode 导出。
interface RoundButtonProps {
label?: string;
}
export function RoundButton({ label = 'Submit' }: RoundButtonProps) {
return (
<div className="flex items-center justify-center w-[120px] h-[40px] rounded-[9999px]">
<span>{label}</span>
</div>
);
}
export function Design() {
return <RoundButton label="Save" />;
}componentStructure: 'flatten' 是回退方案:通过 expandRefSerializedNodes 把每个实例展开为具体 DOM,不产出组件定义。HTML/CSS emitter 始终扁平化,因为 HTML 没有组件概念。
可用于实际界面的示例
下面将运营数据面板、SaaS 定价页和移动端结账页加载到 IC 编辑器画布中。三份设计都包含嵌套 flex 布局与语义化颜色变量;结账页采用 390 × 844 的窄屏画板。你可以在画布中选中、修改节点,再从画布当前文档通过 API.exportCode 更新、复制或下载代码;选择 HTML/CSS 后,点击 HTML 预览 可在沙箱窗口中按画板尺寸查看生成的页面。切换示例会载入另一份可编辑设计。
这些示例在调用 API.exportCode 时按节点 ID 传入 semanticElements,让部分画布矩形和文字在两种导出结果中变成 HTML 页面区域、标题与 <button type="button"> 控件。这份映射属于示例的导出配置;按钮行为和导航仍需在应用中接入。
定价页使用一个 reusable 套餐卡片模板和三个 ref 实例。编辑器会展开实例以便渲染和编辑;此示例会把文字、颜色、排版与文本装饰、透明度、显式尺寸与尺寸约束、间距、Flex 对齐与子项尺寸、纯色描边和投影修改还原为实例覆盖项,供 React 与 .ic 下载使用。Growth 卡片展示了强调色描边。数值型 flexBasis 和尺寸约束在 HTML/CSS 和 React 样式中按像素输出。若卡片结构或不支持的属性被修改,导出会使用展开后的节点,让结果继续与画布一致。
这三个示例的 React 导出也会在 React 19 与 Tailwind CSS 4 环境中运行浏览器测试,并将关键文字位置与 HTML 预览对照,防止生成器改动破坏实际渲染。
包含导航、关键指标、营收图表和最近动态的 SaaS 工作台。
下载 .ic 文件后,可在在线应用中通过 Import from… > .ic 继续编辑设计。下载 HTML 文件后可直接用浏览器打开。选择 下载 React 工程 可从当前画布获取包含 Vite、React 19 和 Tailwind CSS 4 的 ZIP。解压后执行 pnpm install 和 pnpm dev 即可运行,导出的组件位于 src/App.tsx;设计使用 Inter 时,工程会在构建时打包字体文件。当前画布使用的临时 blob: 图片会复制到 public/assets,代码改用本地路径;data URL 继续内联,远程 URL 保持原样。工程中的 src/responsive.css 为这三个示例提供窄屏布局,src/App.tsx 仍保留画板尺寸。也可以只下载 .tsx 文件,放入现有项目。目前生成的是展示层代码;导航、结账动作、支付及实时数据仍需在应用中接入。
反向:code → design
反向(把 JSX/HTML AST 解析回 .ic 节点)更难、歧义也更多,故作为二期。建议先实现本转译器产出代码的幂等回环(design → code → design 不丢信息),再扩展到任意手写代码。
JSON 格式文件
在 课程 10 - 图片导入导出 中我们介绍了如何将图形导出成各种图片格式。在与 AI 交互的过程中,我们需要始终将场景持久化到文件中。在 Figma 中我们可以导入导出 .fig 文件,详见:Import files to the file browser 与 Save a local copy of files
参考 excalidraw 与 pencil 的自定义文件格式,我们也可以设计自己的 JSON 文件,格式为 .ic:
{
"type": "infinite-canvas",
"version": 1,
"source": "https://infinitecanvas.cc",
"variables": {},
"themes": {},
"elements": [],
"appState": {}
}导入时会:删掉当前相机下所有场景根节点,再合并默认 AppState 与文件中的 appState,写入 variables / themes,并在删除完成后 updateNodes(elements);选区里指向不存在节点的 id 会被过滤掉。
import {
downloadIcDocument,
stringifyIcDocument,
} from '@infinite-canvas-tutorial/ecs';
// 导出
const doc = api.exportIcDocument(window.location.origin);
downloadIcDocument(doc, 'my-scene.ic'); // 浏览器里触发下载
// 或自管字符串 / 文件
const text = stringifyIcDocument(doc);
// …写入 .ic 文件或上传
// 导入(可传入对象或 JSON 字符串)
api.importIcDocument(await (await fetch('/scene.ic')).text());在 UI 中,顶部菜单提供了 Export as... > .ic 与 Import from... > .ic,后者会打开文件选择框读取 .ic 文件并调用 api.importIcDocument。
命令行
仓库还提供离线处理 .ic 文件的 CLI(需要 Node.js 20 或更新版本)。先在编辑器中通过 Export as… > .ic 导出设计,保存为 design.ic。然后在仓库根目录按依赖顺序构建并运行:
corepack pnpm install --frozen-lockfile
corepack pnpm --filter @infinite-canvas-tutorial/device-api build:cjs
corepack pnpm --filter @infinite-canvas-tutorial/ecs build:cjs
corepack pnpm --filter @infinite-canvas-tutorial/cli build
node packages/cli/dist/cli.js validate design.ic
node packages/cli/dist/cli.js inspect design.ic --json
node packages/cli/dist/cli.js codegen design.ic --framework react-tailwind --variables-mode css-var --component-name Design --output Design.tsx
node packages/cli/dist/cli.js codegen design.ic --framework html-css --output design.htmlinspect --json 会列出节点数、根节点、可复用节点、变量和资源 URL 数量。不传 --output 时,生成的代码写入标准输出;文档或参数错误会返回非零退出码并输出错误信息。CLI 生成的 React 组件或 HTML/CSS 片段与编辑器代码生成器一致,并使用文档里的变量和主题。它不会访问在线项目;无法跨浏览器进程使用的临时 blob: 图片会被拒绝。导出的文档应使用 data URL 或持久有效的图片 URL。