Penpot 前端 UI 规范与设计系统:CLJS 组件、SCSS 样式与 i18n 工程实践指南
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
本篇技术指南以 Penpot 仓库中记录的Frontend UI Conventions and Style System为核心,系统梳理 Penpot 前端(Rumext/CLJS + SCSS + React/Vite 共享包)在组件编写、样式设计、可访问性、国际化、性能与代码组织上的工程约定。读者读完可以掌握:如何为编辑器/仪表盘编写符合规范的可复用 CLJS 组件、如何借助
stl宏与 CSS Modules 维护低特异性样式、如何正确接入 i18n 翻译流程,以及如何决定一段 UI 代码该放进 CLJS 应用还是frontend/packages/ui。
一、背景:Penpot 前端的技术底座
Penpot 的前端位于仓库的 frontend 目录,采用一套“混合但分层清晰”的技术栈:
- CLJS 应用层:使用 Rumext(v2,命名空间
rumext.v2)驱动的 React 封装,承载编辑器(editor)、仪表盘(dashboard)、查看器(viewer)等完整工作流逻辑; - SCSS/CSS Modules:通过构建期生成的
.css.json映射实现类名哈希与作用域隔离; - 设计系统(DS)目录:位于 frontend/src/app/main/ui/ds,存放与业务状态无关的基础组件(按钮、输入框、弹窗、排版等);
- 共享 React 包:位于 frontend/packages/ui,是框架中立的 React/Vite 组件库,与 CLJS 应用状态(Potok/Rumext)解耦。
下面各节把这份约定文档展开成可直接指导开发的工程规范,并补充对应源码证据。
二、CLJS 应用 UI 的组件编写规范
2.1 组件命名与位置约定
主应用组件统一放在frontend/src/app/main/ui*命名空间树下。约定如下:
- 组件变量名以
*后缀命名(如button*、heading*); - 使用Rumext
mf/defc定义; - 调用点统一写作
[:> component* props]形式。
这一约定在 button.cljs 中有真实体现:
(mf/defc button* {::mf/schema schema:button} [{:keys [variant icon children class on-ref to type] :rest props}] (let [variant (d/nilv variant "primary") element (if to "a" "button") internal-class (stl/css-case :button true ...)] [:> element props (when icon [:> icon* {:icon-id icon :size "m"}]) children]))2.2 清晰的组件所有权:children 优先,slots 谨慎
组件应当具有清晰的“所有权”边界:
- 普通组合用
children; - 只有当需要分隔出独立“属地”(owned regions)时才使用slot 式 props;
- 不要样式化或结构性操作组件自身未实例化的子 DOM——例如一个容器组件不该去假设孙组件的内部结构。
这保证父子组件之间通过 props/children 契约解耦,CSS Modules 的类名也不会跨组件泄漏。
2.3 class prop 的接收与合并:mf/spread-props
当调用方合理地需要布局/定位定制时,组件应接收并合并classprop,并使用 Rumext 的mf/spread-props进行合并。原因是mf/spread-props会继续走 Rumext 的 prop 变换管线(例如:class→className),保证工具链行为一致。
源码中可见其典型用法(button.cljs):
props (mf/spread-props props {:class [class internal-class] :href to :type (d/nilv type "button") :ref (fn [node] (when on-ref (on-ref node)))})这里外层传入的class与组件内部计算出的internal-class合并,同时:href、:type、:ref也一并透传——to存在时渲染为<a>,否则为<button>,是语义化与可定制性的平衡范例。
2.4 规避以?结尾的布尔 prop
避免把布尔 prop 命名为?结尾(如visible?)。这类符号无法干净地翻译成 JavaScript props,在跨 CLJS/JS 边界或与共享 React 包互操作时会产生问题。当 JS 侧的 truthiness/语义重要时,应使用类型提示^boolean显式标注。
2.5 私有组件的本地约定:::mf/private true
需要拆分的大型组件可以拆成若干小的私有组件,::mf/private true是本地约定。仓库内大量使用,例如 register.cljs 中的多处以{::mf/private true}声明私有子组件,避免污染公共导出面。
三、样式体系:stl 宏、CSS Modules 与设计系统 Token
3.1 首选同地共置(co-located)的 SCSS 模块
每个组件与其.scss文件放在一起(如 button.cljs 旁的 button.scss)。通过app.main.style/stl宏族(stl/css、stl/css-case、stl/css*)从 CLJS 侧引用类名。
stl的实现位于 style.clj:它根据当前命名空间所在文件计算 CSS Modules 前缀,读取同名的.css.json映射做真实类名查找,然后把多个类名以空格拼接。关键点在于:
- 命名空间关键字(如
:global/two-row)明确表示“不做 Modules 查找,直接输出原名”; - 普通关键字会被加上哈希前缀后输出;
css/css-case还支持按布尔条件(可为boolean、表达式或运行时求值)决定是否输出某个类。
stl/css-case的典型条件类写法(见 button.cljs):
(stl/css-case :button true :button-link (some? to) :button-primary (= variant "primary") :button-secondary (= variant "secondary") :button-ghost (= variant "ghost") :button-destructive (= variant "destructive"))3.2 保持低 CSS 特异性
- 避免嵌套选择器,除非确实在定位组件自身实例化的元素;
- 由于 CSS Modules 已经杜绝类名碰撞,不需要靠高特异性或深嵌套来“占位”;
- 需要跨浏览器关注态时,优先伪类(
:hover、:focus-visible)而非深层 DOM 选择器。
3.3 优先 CSS 逻辑属性
方向性间距/布局优先用CSS 逻辑属性,例如padding-inline-start、margin-block-end、inset-inline。这样能天然支持 RTL(从右到左)排版。物理width/height在语义更清晰的场景(如图标尺寸)仍然允许。
3.4 一律使用设计系统 Token,禁止硬编码与废弃变量
间距、边框、固定尺寸、颜色、排版必须使用命名好的设计系统变量/Tokens,禁止:
- 硬编码
px/rem; - 使用已废弃的
resources/styles/common/refactor/spacing.scss旧变量。
设计系统 SCSS Tokens/Mixins 集中在 frontend/src/app/main/ui/ds,顶层包含:
| 文件 | 承载的 Token |
|---|---|
| colors.scss | 颜色 Token(如--color-accent-primary) |
| spacing.scss | 间距尺度 |
| _sizes.scss | 尺寸 Token(如$sz-32) |
| _borders.scss | 边框宽度($b-1)、圆角($br-8) |
| _utils.scss | 工具函数(如px2rem) |
| mixins.scss | 排版等复用 Mixins |
| z-index.scss | 层叠级别 |
| elevations.scss | 阴影/抬升 |
例如 _buttons.scss 的组合形态:
@use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; %base-button { height: var(--button-height); border: $b-1 solid var(--button-border-color); border-radius: $br-8; ... &:focus-visible { outline: var(--button-focus-inner-ring-color) solid #{px2rem(2)}; } }3.5 组件多视觉态:优先局部 CSS 自定义属性
当组件拥有多种视觉状态(hover、active、disabled、不同 variant)时,使用组件局部的 CSS 自定义属性(custom properties)驱动主题,而不是一次性 Sass 变量。上述按钮基座即以此设计:先声明--button-bg-color、--button-fg-color、--button-hover-*等占位,再在伪类中做状态切换;variant 具体颜色在%base-button-primary等扩展片段里用--color-accent-*Token 赋值。这样一条规则即可覆盖所有状态,无需为每个状态写死一套新选择器。
3.6 排版:优先 DS 排版组件与 Mixins
不要在业务代码里写裸文本包裹器或旧排版 Mixins。优先使用设计系统排版组件与排版 Mixins:
heading*(heading.cljs)text*(text.cljs)
底层由 typography.scss 等 DS 排版 Token 支撑。
四、可访问性(Accessibility)约定
- 语义 HTML 优先:导航/下载/邮件链接用
<a>,动作用<button>,标题层级(h1–h6)正确,所有控件可键盘聚焦; - 原生元素不可用时补 ARIA:按 WAI-ARIA APG(Authoring Practices Guide)中标准 widget 的模式实现(如组合框、标签页、弹窗、tooltip)。例如 DS 组件对可切换元素使用
aria-pressed(button.scss 的[aria-pressed="true"]分支); - 纯图标控件必须有可访问名称:通过周围文本、
aria-label、alt或等价机制提供;装饰性图标/图片应向辅助技术隐藏(如aria-hidden、空alt)。聚焦可见性统一走:focus-visible+ 自定义 outline。
五、i18n 国际化工程约定
5.1 翻译解析时机
翻译必须在渲染期间或渲染期 memoization中解析,绝不能在命名空间加载期(load time)解析。静态选项列表也应把翻译放入渲染内部的 memo,这样切换语言时标签仍能即时更新。
5.2 翻译文件与构建
- 翻译文件位于 frontend/translations(
.po格式,如zh_CN.po、es.po); - 翻译改动会打进
index.html。翻译字符串没有热重载,改完翻译后要刷新浏览器才能看到效果; - 在
frontend/下执行pnpm run translations重新生成翻译产物(对应 package.json 中的"translations": "node ./scripts/translations.js",实际脚本为 translations.js)。
5.3 新增语言的两处同步
增加一个新的受支持 locale,需要同步更新两个文件,缺一不可:
- i18n.cljs 的
supported-locales(如{:label "简体中文 (community)" :value "zh_cn"}); - _helpers.js 中的
langs数组。
supported-locales还驱动语言自动探测:autodetect会按浏览器语言列表逐一匹配,命中则采用,否则回退到cf/default-language(见 i18n.cljs)。运行时切换语言通过set-locale动态加载./translation.<locale>.js资源(i18n.cljs)。翻译 APIt/tr支持复数形态(app.common.i18n/c标记计数参数,数组型词条按计数取单/复数)与参数格式化(i18n.cljs)。
六、性能约定
- 昂贵派生数据放对地方:存入 refs、memoized selectors 或纯 helper;在热渲染路径上优先复用本地已使用的
app.common.data.macroshelpers(比如依赖更新追踪宏),避免手写重复的相等性判断; - 避免在热渲染中新建回调/对象:能用命名函数、memoized callback(如
mf/use-callback)、data 属性或预计算的 JS props 对象时,就不要内联新建fn与字面量对象,防止子组件无意义重渲染; - 重复使用 props/state 时先解构:在渲染循环内避免反复 deref 或重复属性访问。
七、共享 React UI 包(frontend/packages/ui)
7.1 包的定位
frontend/packages/ui 是共享的 React/Vite 包(含package.json、vite.config.mts、TS 配置与 Storybook 配置)。它相对 CLJS 应用状态保持framework-neutral:
- 可复用原语只有不依赖 Potok/Rumext 应用状态时才放进这里;
- 它的消费场景是“无需 Penpot 应用状态即可渲染”的展示型组件。
7.2 样式的发布路径
包样式通过包的构建产出,并拷贝到frontend/resources/public/css/ui.css(该行为在 vite.config.mts 中配置)。因此“共享样式过期”绝大多数时候是构建产物(artifact)未刷新的问题,而不是源码问题——遇到 UI 表现与源码不一致,先重新构建包。
7.3 Storybook 是主要视觉验证工具
Storybook 是共享 UI/包行为的主要视觉调试载体。包与组件测试构建/运行命令用记忆入口mem:frontend/ui-packages-text-editor-workflow。DS 组件的每个原语通常同时提供:
- CLJS 实现(如 button.cljs);
- CSS Module(button.scss);
- Storybook story(button.stories.jsx);
- 可选 MDX 文档(如 buttons.mdx)。
八、代码该放哪里:位置选择决策
| 场景 | 放置位置 | 依据 |
|---|---|---|
| 编辑器/仪表盘/查看器工作流逻辑 | CLJS 应用命名空间,贴近所属 feature | 依赖 Potok/Rumext 应用状态与画布状态 |
| 可脱离 Penpot 应用状态消费的可复用展示原语 | frontend/packages/ui | framework-neutral |
| 使用 DS 的 CLJS 设计系统组件 | frontend/src/app/main/ui/ds | 新 DS 组件需四件套:实现、CSS module、Storybook story、可选 MDX 文档 |
| 文本编辑内部逻辑(属 JS 编辑器包行为) | frontend/text-editor | JS 包内维护;共享文本数据模型行为走mem:common/text-subtleties |
**新增 DS 组件(第 3 行)**还要从 frontend/src/app/main/ui/ds.cljs 导出,并使用JavaScript-friendly 名称。该文件以mf/object构造一个default导出对象(如:Button button*、:Heading heading*、:Input input*…),用于 Storybook 与跨语言消费。
九、变更后的验证清单
按改动范围选择验证手段:
- CLJS 应用 UI:跑
mem:frontend/testing与mem:frontend/compile-diagnostics;当行为依赖 store 或画布状态时,补浏览器/REPL 实机检查; - 共享 UI 包:跑包构建 + Storybook/组件测试;
- 文本编辑器:跑 frontend/text-editor 的测试;若涉及 render-wasm 产物,需刷新/拷贝 WASM 产物后再验证。
这一“按层选择验证手段”的原则,与上文“按层组织代码”的原则一一对应:改动落到哪一层,就用哪一层的测试与工具链收口,确保视觉与行为双回归都被覆盖。
十、总结
Penpot 的前端约定可以概括为一句闭环:组件有清晰属地主权、样式低特异性且 Token 化、翻译渲染期解析、共享 UI 与应用状态解耦、新增 DS 组件四件套齐备、按层验证。实践中最容易踩坑的三点:忘记类名走stl宏(破坏 CSS Modules 前缀映射)、在命名空间加载期解析翻译(切语言不生效)、改动frontend/packages/ui后没重建产物(ui.css陈旧)。对照本文规范与对应的源码实现,即可写出与 Penpot 主前端代码风格一致、可无障碍维护的 UI 代码。
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考