大多数设计工具把代码当作下游问题。你在一个私有文件里设计,把它交接出去,然后祈祷工程师正确阅读规范。Penpot 建立在相反的假设上。文件格式是开放的,token 模型遵循 W3C 标准,所以它不用翻译层就能映射到代码变量,插件 API 让你能用程序读取设计。
我花了一次会话通过插件 API 驱动 Penpot,来整合一个设计系统并重建一个引导板。这篇帖子的其余部分是我在 Penpot 如何为这个工作流而建方面学到的东西,以及过程中遇到的摩擦。
文件格式是开放的
Design-to-code 从文件开始。如果你的设计活在一个封闭的二进制 blob 里,你就只能依赖厂商给你的导出。
一个 .penpot 文件是一个 ZIP 归档,包含可读的 JSON 元数据和图片这样的二进制资源。不是私有 blob。v3 格式包含一个清单、文件元数据、页面、形状、库资源、存储对象、插件数据和嵌入的图片。
这段历史值得知道。v1 的 .penpot 格式是一个自定义的二进制 blob。Penpot 还提供了一个单独的 .zip 导出,是 SVG 和 JSON,开放但低效。当前的 v3 .penpot 格式是两者之长:一个带 JSON 元数据的 ZIP 容器,既可检视又高效。你的设计数据是你的,用你能读的格式。
这是其他一切建立的基础。你不用请示就能把数据拿出来。
Token 就是合同
Token 是设计和代码之间的桥梁。Penpot 里的一个 token 是一个有名字的值:一个颜色、一个排版样式、一个边框圆角、一个间距值。当你把一个 token 应用到一个形状上时,形状记住了值和绑定。
当你通过插件 API 读取一个形状时,你得到两者:
shape.tokens.fill // "canvas"
shape.fills // [{ fillColor: "#000000", fillOpacity: 1 }]shape.tokens.fill 是 token 名字,作为字符串。shape.fills 是解析后的视觉值。绑定和值,并排放着。
这就是合同。如果 Penpot 的 token primary 映射到 CSS 变量 --primary,那么一个 tokens.fill === "primary" 的形状直接映射到 background: var(--primary)。设计文件里的 token 名字变成代码库里的 token 名字。没有翻译层,不用猜哪个 hex 值对应哪个角色。
这能行是因为 Penpot 的 token 遵循 W3C Design Tokens Format Module。Penpot 是第一个原生集成这个标准的设计工具,与 Tokens Studio 合作开发。token 名字不是 Penpot 特有的。它们在一个支持该标准的任何工具都能读的格式里。
Token 活在集合里
API 的形状反映了模型。TokenCatalog 不让你直接添加 token。它只暴露集合操作:
interface TokenCatalog {
addSet(...)
getSetById(...)
sets
themes
}
interface TokenSet {
tokens: Token[]
addToken({ type, name, value }): Token
}token 在 TokenSet 上创建,不在目录上。文档说得很清楚:"Tokens are contained in sets." 没有游离的 token。
一个值得知道的坑:Penpot 把 token 名字里的点当作路径。我在 token 名字里放了文件夹前缀,比如 colors.primary,结果在 Colors token 类型下多了一个 colors 文件夹。扁平名字解决了。
主题就是 Light 和 Dark 的运作方式
从 Figma 过来,你会以为 Light 和 Dark 是一个集合上的模式。Penpot 的分法不同。Light 和 Dark 是主题,主题是集合的组合。
你把基础 token 放在一个集合里:品牌颜色、间距、圆角。你把 Light 特有的颜色放在 Light 集合里,把 Dark 特有的颜色放在 Dark 集合里。然后你把它们组合成主题。Light 主题启用 Base 和 Light 集合。Dark 主题启用 Base 和 Dark 集合。切换主题,整个板子就换了。
API 反映了这一点。TokenCatalog 把 sets 和 themes 作为并列项暴露。你构建集合,然后配置每个主题激活哪些集合。themes 属性就在上面的代码片段里,我第一次读的时候跳过了。
跟 Figma 的对比
如果你从 Figma 过来,映射关系是:Figma 集合对应 Penpot 集合,Figma 模式对应 Penpot 主题。一个 Figma 集合包含相关的变量,一个模式为不同上下文存储平行值。在 Penpot 里,一个集合包含相关的 token,一个主题为某个上下文组合集合。
区别在格式。Figma 的变量用私有格式。要用 W3C 标准格式从 Figma 里拿出 token,需要一个像 Tokens Studio 这样的插件。Penpot 原生导出 W3C 格式。两者实现的是同一个概念。一个说标准,一个说自己的方言。
名字冲突是按集合算的,不是按类型
我想要一个 Base 集合包含所有东西:颜色、排版、圆角、间距。圆角和间距的自然命名是 xs、sm、md、lg、xl。
Penpot 拒绝了:
A token already exists at the path: xs or at a prefix thereof.唯一性规则是按集合算的,跨所有类型,不是按类型。所以 xs 不能在同一个集合里同时是 borderRadius token 和 spacing token。
解决办法是加前缀:radius-xs、radius-sm、space-xs、space-sm。颜色和排版保持扁平名字,因为它们不跟任何东西共享名字。冲突只在两个尺度想要同样的短名字时出现。在设计你的 token 命名方案之前值得知道。
Token 适用在哪里(以及不适用在哪里)
不是每个 token 都适用于每个属性。API 强制执行这一点。
我想把每个间距 token 显示为一个小条,其长度匹配 token 值,并把 token 应用到那个条上。API 拒绝了:
Field message is invalid.我试了 marginLeft、paddingLeft、paddingTop。全都被同样的错误拒绝。间距 token 的文档化目标是 flex 布局属性:rowGap、columnGap、padding 属性和 margin 属性。实际上,在一个通过 addFlexLayout() 添加了 flex 布局的板上,只有 rowGap 和 columnGap 接受了 token。Padding 和 margin 属性一直抛验证错误。
所以间距 token 不是通用尺寸。它们适用于布局间隙。为了在视觉上表示一个间距 token,我建了一个带 flex 布局的小板,把它的 columnGap 设为 token。板里的两个矩形,被那个间隙隔开,就是间距值的可见表示。
颜色、排版和边框圆角 token 干净地适用于填充、文本和角。这跟你在代码里用它们的方式一致:颜色放在填充上,排版样式放在文本上,圆角放在角上。间距放在元素之间的间隙上,在 Penpot 里就是 flex 布局间隙。
组件按规范映射,不是靠魔法
一个 Penpot 组件是一个设计对象,不是代码文件。当我通过 API 操纵组件时,我在改变 Penpot 设计文件里的对象:矩形、文本、组、库组件。不是 React,不是 CSS,不是仓库里的文件。
一个 Penpot 组件是一个可重用的设计对象,有一个主实例和副本。编辑主组件,实例就更新。它有视觉属性:填充、描边、排版、布局、token。它不住在仓库里。
要把一个 Penpot 组件变成代码,你手动映射。一个 button-primary 设计组件变成一个 React 按钮,CSS 用同样的 token 名字。一个 Penpot 屏幕变成一个应用视图。token 名字是桥梁。设计对象和代码组件通过命名和规范关联,不是通过任何自动转换。
这是 design-to-code 的诚实版本。工具给你一个共享词汇表(token)和一个开放文件格式。它们不替你写组件。映射仍然是一个人看着设计写代码,但合同是明确的,不是猜的。一个像 DESIGN.md 这样的格式可以把这些 token 名字带进仓库,这样 AI 代理或开发者读到的就是设计工具导出的同一份源。
API 让你能检视一切
design-to-code 工具需要用程序读取设计。插件 API 就是为这个建的。
历史有两层,这能让你感受到 API 是怎么组织的。撤销历史是会话内的编辑时间线。插件 API 可以用 penpot.history.undoBlockBegin() 和 penpot.history.undoBlockFinish(block) 把编辑分组到一个撤销步骤。把一批编辑包在一个块里,一次撤销就回退全部。
文件版本是保存的检查点。API 暴露 penpot.currentFile.saveVersion("label") 和 penpot.currentFile.findVersions()。一个保存的版本是你以后可以回到的点,跨会话。我通过 saveVersion 把整合后的状态保存为 Version 1。返回值碰到了包装器里的一个序列化 bug,但 findVersions() 确认版本存在,标签正确。保存成功了,虽然返回值没有干净地序列化。
重点是设计文件是可查询的。你可以遍历形状树、读取 token 和填充、列出集合、保存版本、追踪什么变了。这就是 design-to-code 管道需要从中获取的东西。
导出和实际情况
导出是我遇到最多摩擦的地方,日志值得读,因为它们展示了导出器怎么工作。
导出工具一直失败。先是 http error,然后是 30 秒后的超时。一个 100x100 的带红色填充的测试板也超时了,所以板的内容不是问题。
Penpot 导出器的日志显示了原因:
ERR [app.handlers.export-shapes] hint="unexpected error on single export"
page.goto: net::ERR_CONNECTION_REFUSED at http://penpot-frontend/render.html导出器是一个无头浏览器,它导航到一个渲染 URL 并截图。它在尝试访问 http://penpot-frontend/render.html,收到连接被拒绝。那是一个部署问题。导出器容器无法解析或访问前端服务的主机名。
修复之后,日志变了:
INF [app.renderer.bitmap] uri="https://penpot.example.com/render.html?..."导出器现在通过公网 URL 访问前端并渲染。exec:handle:end 行显示浏览器完成了工作。我调用的 MCP 工具仍在 30 秒超时。渲染在服务端完成了。把结果返回给我的包装器是瓶颈。
结论:当 Penpot 导出失败时,读导出器日志。错误指出了导出器试图访问的 URL。在内部主机名上的 ERR_CONNECTION_REFUSED 意味着导出器找不到前端。日志里没有错误的超时通常意味着渲染本身慢,或者工具包装器是限制,不是 Penpot。
Penpot 在 design-to-code 上做对了什么
Penpot 不是魔法。你仍然写代码。Penpot 给你的是一个建立在让 design-to-code 成为可能的假设上的设计工具:
- 一个你能检视和提取的开放文件格式。
- 一个基于 W3C 标准的 token 模型,绑定和值都可读,主题处理 Light 和 Dark 不用复制文件。
- 一个让工具能遍历设计并读取合同的插件 API。
- 通过命名和规范映射的组件,不锁定你怎么实现它们。
