henji-ui-surface
GitHub提供界面表面与层级规范,解决卡片嵌套、条带重叠等UI问题。指导页面骨架构建、组件选型及视觉美化,确保布局合理与性能优化。
Trigger Scenarios
Install
npx skills add henjicc/Henji-AI --skill henji-ui-surface -g -y
SKILL.md
Frontmatter
{
"name": "henji-ui-surface",
"description": "Henji-AI 新建或改造任何界面\/页面骨架\/面板\/弹窗\/侧栏\/设置分区\/节点 UI,或调整按钮层级、分隔线、颜色、图标、毛玻璃、动画、层级时使用。主文件涵盖页面骨架的横向条带上限、表面层级(surface\/elevation)铁律、五级容器词汇表、动作按钮三档、分隔线准入、选项集合静息态与选中态词汇表;颜色\/材质、动效、图标、排版令牌、性能分层、静默失效坑拆在 references\/ 按需读。触发场景:用户要求\"做一个 XX 面板\/页面\/弹窗\"、\"这个界面不好看\/太挤\/像卡片套卡片\"、\"顶部堆了好几行\/几个条\/布局不合理\"、\"标题栏和工具栏能不能合并\"、\"这块儿怎么像张卡片\"、\"帮我美化一下这个界面\"、\"加一个设置分区\"、\"统一一下 UI\/配色\/动画\/模糊\"、\"这个动画太快\/太慢\/很生硬\"、\"这个界面卡顿\/拖动掉帧\"、\"切换主题后有些地方没变色\"、\"为什么有的按钮有边框有的没有\"、\"这里要不要加分隔线\"、\"图标不一致\"。"
}
Henji-AI 界面表面与层级规范
为什么需要这份规范
项目的 Ui* primitives 已经组件化,但组件化 ≠ 界面好看。实测本仓库最常见的三类问题:
- 卡片套卡片:
AssistantSidebar(bg-panel+ border)里放AssistantConversation的消息块(又是bg-panel+ border);Settings/index.tsx(bg-panel弹窗)→SectionCard(又一层bg-panel)→ 内部行(第三层bg-surface-dark)。三层边框叠在一起,视觉上就是"一张卡里弹一张卡又套一张"。 - 条带叠条带:工具箱 → 图片编辑,从窗口顶到画布之间横着切了 4 刀(标题带 / "打开图片"带 / 工具带 / 样式带),分别来自 3 个文件,其中两条同底色的带中间还夹了一条透明带。
- 该扁平的地方带了壳:
UiPanel/UiIconButton/UiOptionButton的默认值自带 border + bg,每个组件都假设自己是最外层独立卡片。在容器内部使用时,就会多出一层不该有的边框背景。
前两类是同一个根因在两个方向上的表现:每一层壳都以为自己是最外层,于是纵向各画一圈边框、横向各加一条头带。第 3 类则是表面 token 把 border 和 bg 打包绑死(见 styleTokens.ts 的 UI_PANEL_SURFACE_CLASS / UI_FIELD_SURFACE_CLASS),且没有"只分组、不画框"的官方写法。本 skill 提供这几条缺失的规则。
参考文档(按需读,不要一次全读)
本文件只放每次改 UI 都要用的骨架规则。细分主题拆在 references/:
| 什么时候读 | 文件 |
|---|---|
调颜色、写 .css、加毛玻璃、改对比度 |
references/color-and-material.md |
写任何过渡/动画,或用 setTimeout 卸载动画组件 |
references/motion.md |
| 用到任何图标 | references/icons.md |
| 定字号/圆角/阴影/层级/间距 | references/typography-and-tokens.md |
| 界面卡顿、拖动掉帧、长列表 | references/performance.md |
| "我改了但没生效" | references/pitfalls.md |
两条铁律(先记住这两句)
纵深:同一层视觉深度,只画一次边框/背景。 进入一个已经有边框或背景的容器后,内部分组必须改用留白 / 分隔线 / 更暗的底色,不得再叠一层
border + bg + rounded。
水平:一个视图只画一条命令带。 返回、标题、文件上下文、工具、导出动作全部进这一条;随工具变化的参数用紧贴其下的从属带,且与命令带共用同一块底色和同一条下边框。
参考 Atlassian 的表述:能用边框或留白区分时,就不要用抬升(卡片)来分组。成熟设计系统普遍只保留 4~6 个层级并刻意克制。水平方向同理——桌面编辑器(VS Code、Figma、Photoshop)顶部一律是一条命令带加一条可选的上下文带,不会因为壳换了一层就多长一条。
五级容器词汇表(先背这张表)
写任何界面前,先确定"我在第几级",然后只用那一级允许的东西。 从 Region 往下选,能停在哪级就停在哪级——不要一上来就用 Card。
| 级 | 概念 | 组件 | 边框 | 背景 | 阴影 | 用途 |
|---|---|---|---|---|---|---|
| 1 | Region 页面区域 | <UiRegion> |
❌ | ❌ | ❌ | 页面主区,只管外边距与最大宽度 |
| 2 | Group 分组 | <UiGroup title=…> |
❌ | ❌ | ❌ | 普通内容分组的默认选择:标题 + 间距 |
| 3 | Divided 分隔 | <UiGroup divided> |
仅一条线 | ❌ | ❌ | 需要明确切分时 |
| 4 | Surface 内嵌面 | <UiPanel variant="inset"> |
❌ | 更暗底 | ❌ | 代码块、只读预览、列表项(父级已是卡片时) |
| 5 | Card 卡片 | <UiPanel> |
✅ | ✅ | ✅ | 仅浮层/弹窗/侧栏/画布节点 |
<UiPanel> {/* 5 卡片:border + bg-panel + shadow-panel + rounded-xl */}
<UiPanel variant="inset"> {/* 4 内嵌:仅 bg-app/40 + rounded-lg,无边框无阴影 */}
<UiPanel variant="bare"> {/* 4 纯容器:只有圆角 */}
<UiGroup title="基础设置"> {/* 2 分组:零装饰,标题 + 间距 */}
<UiGroup divided> {/* 3 分隔:上方一条线 */}
方向铁律:内层背景只能比外层更暗,不能更亮。 比父级亮 = 视觉上"浮起来" = 卡片。
本项目 bg-app(10) < bg-panel(23) < bg-surface-dark(38) < bg-layer(64)。
在 bg-panel 的弹窗里用 bg-surface-dark 做分区,就是在造卡片。
卡片准入条件(四条全中才允许)
- 有独立交互或独立状态
- 可被单独移动 / 关闭 / 拖拽
- 与兄弟元素是并列实体(列表项、画布节点)
- 脱离页面上下文仍能被理解
卡片嵌套上限:1 层。 卡片内部一律用 Group / Surface。
决策树:这个容器要不要边框背景?
我正在写的这个 div,它的父级链上已经有 border 或 表面 bg 了吗?
├─ 没有(我就是最外层浮层/弹窗/侧栏/画布节点)
│ → <UiPanel>。不要手写 border + bg-panel + rounded。
│
└─ 有(我在某个 panel 内部)
├─ 只是想把几个字段归成一组 → <UiGroup title="…"> ← 默认走这条
├─ 需要明确切分 → <UiGroup divided>
├─ 要让这块"沉下去"(代码块/只读预览/列表项)→ <UiPanel variant="inset">
└─ 想不出理由,只是"看着空" → 什么都不加。留白就是设计。
"想不出理由就不加" 是本规范最重要的执行细节 —— 绝大多数丑陋的套娃,都来自"这里看着空,加个卡片吧"。
页面骨架:横向条带(做整页/工具页之前先看这节)
上面几节全部在管纵深。这一节管水平:从窗口顶到内容区之间,横着切了几刀。
组件级审查(每个按钮用没用对 primitive)发现不了这类问题——每一条带单独看都合规,丑的是它们摞在一起。所以看整页时的第一个动作是数条带,不是看按钮。
三类条带与数量上限
| 类 | 名称 | 每视图允许 | 装什么 | 视觉 |
|---|---|---|---|---|
| A | 命令带 | 恰好 1 条 | 返回、标题、文件上下文、主工具组、导出/保存动作 | h-10~h-11 + border-b border-border-dark + bg-surface-dark + px-2 |
| B | 从属参数带 | 0~1 条,必须紧贴 A 下方 | 只随当前工具变化的参数(颜色、线宽、字号) | 不自带底色、不自带边框,与 A 在同一个容器里,共用 A 那条 border-b |
| C | 状态带 | 0~1 条,页面底部 | 只读状态、进度、计数 | 无边框,text-text-muted |
连续操作条带上限 = 2(A + B)。 出现第三条就是骨架错了,不是间距问题。
决策树:这东西要不要新开一条带?
我要往页面顶部加一个东西,它是什么?
├─ 返回 / 标题 / 文件名 / 主动作 / 工具 → 进现有命令带(没有就建**一条**)
├─ 只随当前工具变化的参数 → 从属带,紧贴命令带,不另画底色与边框
├─ 只读状态、进度、计数 → 命令带右端 `ml-auto`,或页面底部状态带
└─ "它跟上面那些不是一类,单独放一行吧" → 停。先问它是不是上面三类之一,
99% 的情况是,只是懒得往已有带里塞。
外层壳已有命令带时:注入,不要再嵌一层壳
功能组件被塞进一个已经有命令带的外壳里时,不能自己再长一条头带。正确做法是把内容作为 props 注入外层那一条带——项目里已经有这个出口,ImageEditor 的 toolbarActions 就是(复制 / 加入资产库 / 另存为三个按钮就是这么进到工具带右端的)。
// ❌ 外壳已经有一条命令带了,功能组件又开一条自己的行
<div className="p-4">
<div className="flex items-center gap-2"> {/* 第二条带:只为了放一个按钮 */}
<UiButton variant="ghost">打开图片</UiButton>
<span>{fileName}</span>
</div>
<Editor />
</div>
// ✅ 注入到已有的那条命令带里
<Editor
toolbarLeading={<><UiButton variant="ghost">打开图片</UiButton><span>{fileName}</span></>}
toolbarActions={<UiButton variant="primary">另存为…</UiButton>}
/>
返回入口的三种形态(不要为返回单开一条带)
返回按钮的位置由这个页面长什么样决定,不由"哪个文件画的"决定:
| 页面类型 | 返回落点 | 例子 |
|---|---|---|
| 有页面标题的二级页面 | <UiPageHeader onBack backLabel>,渲染在标题左侧 |
3D 镜头参考工程列表、图片编辑空态、资产库工作区 |
| 自带命令带的全屏工作面 | 那条命令带的左端 | 3D 场景编辑器、图片编辑器 |
| 没有命令带的全屏工作面 | 浮在内容上的玻璃返回按钮 | 画布项目内 |
禁止为"返回 + 页面名"单画一条 h-10 横带。 它会和应用标题栏叠成"双标题栏",
而且页面名通常和下面的页面标题重复一遍。
实测踩过:ToolboxWorkspace 曾统一画一条「← 工具名」带,于是 3D 镜头参考列表页
纵向出现两遍"3D 镜头参考";为了让编辑器形态只剩一条带,又加了 ownsCommandBar
与 view !== 'editor' 两个开关逐个工具关掉。判据换成"页面有没有标题/有没有命令带"
之后,那两个开关连同整条带一起删掉了。
同一批返回入口当时长成四种样子:外层条带图标、工具自绘条带图标、命令带左端图标、 画布上的玻璃文字按钮,以及资产库放在标题右侧动作区的文字按钮。用户在应用里 换一个页面就要重新找返回在哪——这是"每处各自决定"的必然结果,不是审美问题。
实测:同一个工具箱里,好例子和坏例子并存
| 视图 | 条带数 | 情况 |
|---|---|---|
| 工具箱 → 3D 镜头参考 | 1 条 | ✅ CameraStageEditor 的 h-11 带里塞下了返回 + 撤销 + 快捷添加 + 视口工具 + 中间路径上下文 + 右端状态/徽标/设置 |
| 工具箱 → 图片编辑 | 4 条 | ❌(已修)ToolboxWorkspace 标题带 → ImageMarkTool 的"打开图片"裸行 → ImageEditorShell 工具带 → 样式带;外层标题带已按上一节删除,返回改进页面标题 |
更值得注意的是:ToolboxWorkspace 里曾有个 showToolHeader 开关,专门为 3D 镜头参考关掉外层标题带,好让它只剩一条。也就是说这个问题早就被撞见过,但当时是给单个工具开特例躲过去的,没有沉淀成规则——于是下一个工具(图片编辑)原样又撞了一次。现在外层标题带整条删除,那个开关也不复存在。
结论:特例是规则缺失的信号。 再看到"为某个页面单独关掉某段骨架"的开关时,先问它是不是该反过来变成默认。
横向 padding 必须对齐
图片编辑那 4 条带的横向 padding 分别是 px-2 / p-4 / px-3,于是返回箭头、"打开图片"、工具组的左端落在三个不同位置。同一视图内所有条带与其下的内容区用同一个横向 padding,改一处就该一起改。
全屏工作面不套卡片
画布、编辑区、预览区这类铺满剩余空间的工作面不是卡片——对照「卡片准入条件」四条:不能单独移动、不是并列实体、脱离上下文无意义,一条都不中。
// ❌ MarkCanvas 现状:工作面自带卡片外观,外层再给 p-4 空白,于是整个编辑器浮成一张卡
<div className="rounded-xl border border-veil-subtle bg-bg-dark/85">
// ✅ 铺满,边界由它和命令带之间的那条 border-b 表达
<div className="bg-bg-dark/85">
判据:这块区域会不会随窗口一起长大? 会,就不是卡片。要给它一个更暗的底以便和 chrome 区分是可以的,但不要 rounded + border + 外层留白三件套——那三样凑齐就是卡片。
动作层级:视觉重量 = 动作的重要性
上面几节管容器和骨架,这一节管按钮本身该有多重。
主流设计系统都是同一个阶梯(Material 的 filled/outlined/text、Apple 的 prominent/bordered/plain、Fluent 的 primary/default/subtle),本项目对应三档:
| 档 | UiButton variant |
图标版 | 用途 |
|---|---|---|---|
| 主 | primary(实底) |
—— | 一个表面只允许一个,这一屏的主动作 |
| 次 | ghost / muted(描边) |
UiIconButton(默认带边框) |
常用但非唯一的动作 |
| 辅 | plain(无边框,hover 出底) |
UiIconButton showBorder={false} appearance="hover-only" |
工具栏、行内辅助动作 |
两条硬规则
- 一个表面只有一个主动作。 出现第二个实底按钮,用户就不知道该点哪个。
- 同一组、同一层级的动作必须同档。 不能因为"这里太挤了"把其中一个降档——那是拿视觉语言解决布局问题, 用户读到的信息会变成"这个按钮没那么重要",而事实不是。
实测踩过:图片编辑命令带右侧的「打开」和「复制 / 加入资产库」同属文件类次级动作, 为了给工具组腾 44px 宽度被降成了无边框图标,一眼就看出不对。宽度问题要用缩短文案、 图标化整组、或接受轻微偏移来解决,不能只降其中一个。
⚠️ ghost 与 muted 目前视觉等价(都是描边 + 底色),是历史命名,
不要按字面理解成"无边框"——真正的无边框档是 plain。
动作 ≠ 模式
工具栏里的工具(选择/标注/矩形…)不是按钮,是"我现在处于哪个模式",
点它改变的是"接下来会发生什么",不是"立刻发生一件事"。
它属于选中态语言,不属于动作层级:静息不描边,选中用中性层底 + 强调文字
(UiChipButton selectionRole="navigation"),把实底强调色让给那个唯一的主动作。
同理,参数面板里的"当前值是什么"(形状、比例、档位)是单选,
用 UiOptionButton active(强实底 + 白字)——详见「选中态词汇表」。
分隔线:分组的第二手段,不是第一手段
分组有三级,按顺序往下选,能停在上一级就别用下一级:
- 间距(格式塔邻近律)—— 首选,零视觉成本
- 分隔线 —— 间距不够用或空间紧张时
- 容器 / 边框 —— 最后手段(回到「五级容器词汇表」)
准入判据(只有一条)
两侧的交互语义根本不同,用户不会把它们当成一串连续操作。
| 场景 | 判定 |
|---|---|
| 工具组 ┃ 撤销/重做/清空 | ✅ 模式 vs 动作——点工具是改变后续行为,点撤销是立刻发生一件事 |
| 打开 ┃ 复制/加入资产库/另存为 | ❌ 都是动作,只是输入 vs 输出。差异远小于上一行,间距就够 |
| 两组同类按钮,只是"感觉该分开" | ❌ 加大间距 |
数量上限
一条 bar 上最多一条分隔线。 第二条会把它切成三段,而右端的主动作实底本身 已经是"终点"标志,再加竖线的信息增量接近零。
选项集合的静息态:不描边
上面的决策树管容器,这一节管容器里那一堆并列的可点项(菜单项、模型网格、分辨率格子、列表行)。
边框表达的是"边界",不是"可点击"。 一屏里几十个选项各自描边时,边框互相抵消、不再传递任何信息,只剩视觉重量。 可点击性由 hover 反馈 + 排布规律表达,不需要静息态的框。
UiOptionButton 的 variant="menu" 就是这条规则的落点:静息态无边框无底色,hover 出 bg-layer,选中态才是实底。
判据(两条都要满足才用 menu)
- 是同质选项的集合:≥3 个由
map渲染的并列 peer,或语义上明确的二选一分段。孤立的单个按钮不算 —— 那种情况下框才真的在划定边界。 - 去掉框之后形状还在。满足任一即可:
- 已被可见容器圈住(浮层面板、弹窗左栏、下拉列表)——容器已经画过一次边界了
- 每项自带足以撑出形状的内容(缩略图、图标块、多行文本、比例示意图)
- 是二维网格 —— 此时补一层
bg-veil-faint撑格子,但仍然不描边(底色已经表达过一次边界,边框是多余的第二次)
反例:这些要保留边框
| 场景 | 为什么 |
|---|---|
纯文字 chip 组(筛选 chips、数值 marks、CompositeRadio) |
直接落在面板底色上,去框后变成裸文字,点击可供性丢失 |
| 内容入口卡(工具箱、工程列表) | 是内容卡不是选项,走 Card |
表单单选 RadioInput |
框就是命中区域 |
| 动作按钮("上传音频"、"选择文件") | 是按钮不是选项,走 variant="flat" |
选中态词汇表:先判断语义,再选强度
“选中”不是一种视觉,而是四种不同语义。业务层优先传递 active / checked 与
selectionRole,由 Ui* primitive 消费 styleTokens.ts 中的状态令牌,不要在调用点
复制蓝底、蓝框或强调文字。
| 语义 | 表达 | 通用落点 |
|---|---|---|
| 导航:正在看哪里 | 中性层底 + 强调文字 + 方向指示条 | UiNavButton active;横向 chip 用 selectionRole="navigation" |
| 单选:当前值是什么 | 强品牌实底 + 白字 | UiOptionButton active |
| 多选/标签:集合中哪些已选 | 强调描边 + 中性层底 + 强调文字 | UiChipButton active |
| 布尔:功能是否开启 | 强调色只进入开关轨道或复选框本体,整行保持静息 | UiSwitch checked / UiCheckbox checked |
默认态不是第五种选中态:它保持当前表面的中性视觉。不要用整行实底表达“已启用”, 也不要把筛选 chip 的多选语义画成单选项的强实底。
状态展示统一走这三个
页面不要自己写空/加载/错误块(历史上因此出现同一状态在不同页面长得不一样):
<UiEmpty title="还没有供应商" description="先添加一个吧。" />
<UiLoading message="生成中…"><ProgressBar progress={p} /></UiLoading>
<UiError message={err} onRetry={retry} />
状态块不画卡片——它已经在某个容器里了。
布局与表单
<UiRegion maxWidthClassName="max-w-3xl">
<UiPageHeader title="生成历史" description="共 128 条" actions={<UiButton>清空</UiButton>} />
<UiGroup title="基础设置">
<UiFormRow label="语言" hint="影响界面与模型提示词">
<Dropdown … />
</UiFormRow>
<UiFormRow label="启用快速下载" inline>
<UiSwitch … />
</UiFormRow>
</UiGroup>
</UiRegion>
分区之间的间距用 UI_SECTION_STACK_CLASS,不要每处自己定 space-y-*。
硬性禁止清单
| 禁止 | 正确做法 |
|---|---|
业务组件手写 rounded-xl border border-border-dark bg-panel |
<UiPanel> |
在 UiPanel 内部再放一个 border + bg 的 div |
variant="inset" / "bare" / 纯留白 |
用 UiIconButton 默认态(自带边框)做工具栏密集图标 |
showBorder={false} 或 appearance="hover-only" |
| 容器内的同质选项集合逐项描边 | UiOptionButton variant="menu",见"选项集合的静息态" |
在 UiOptionButton 调用点手写 !border-transparent !bg-transparent hover:!bg-layer |
用 variant="menu",别再复制这串 |
面板/弹窗内部再叠一层自己的底色(bg-zinc-900/40 这类) |
表面由外壳统一提供;要切分用分隔线,要下沉用 inset |
用 panelClassName 覆盖 PanelTrigger / Dropdown 的外壳表面 |
不传即可;同级浮层长得不一样多半就是这么来的 |
zinc-* / gray-* 等固定调色板 |
语义色,见「颜色必须跟随主题」 |
自己拼 backdrop-blur-* + bg-black/xx + border-white/xx |
ui-glass;且先确认这个浮层真的压在媒体/画布上 |
text-zinc-600 dark:text-zinc-400 双分支 |
直接写最终值,dark: 的基础值是死代码 |
| 给已经带边框的控件外面再包一层框 | 去掉外层框 |
| 为"填充空白"添加卡片、边框、阴影 | 留白 / 调整间距 |
| 同一屏出现 3 层以上叠加边框 | 重新走上面的决策树 |
复制其他文件的 border + bg class 串当模板 |
先判断目标位置在第几层 |
| 外层壳已有命令带,内层功能组件再长一条自己的头带 | 把内容作为 props 注入外层那条带(toolbarActions 那种出口) |
| 为一个"打开文件 / 新建"按钮单独占一整行 | 塞进命令带左端,文件名跟在按钮后面 |
| 两条同底色条带中间夹一条透明带 | 合并成一条;确实要分就让中间那条也归属同一块底色 |
全屏工作面(画布/编辑区/预览区)套 rounded + border + 外层留白 |
铺满,边界交给上方那条 border-b |
| 同一视图里各条带用不同的横向 padding | 统一到同一个值,与其下内容区对齐 |
业务组件手写 inline <svg> 画图标 |
用 lucide-react;确属图形则加入 check-icon-tokens.cjs 豁免并写明理由 |
| 在调用点自己从 lucide 挑业务概念图标 | 用 @/core/theme/icons 的登记常量 |
| 建一个「本目录自己的图标模块」 | 删掉,调用点直接用 lucide;私有图标集=又一套平行体系 |
一个表面出现两个 variant="primary" |
只留一个主动作,其余降到 ghost |
| 为了省宽度把同组动作里的一个降档 | 缩短文案 / 图标化整组 / 接受轻微偏移,不要只动一个 |
| 用分隔线分开两组同类动作 | 加大间距;分隔线只用于交互语义根本不同的两侧,一条 bar 最多一条 |
| 把工具/模式切换写成带边框的按钮 | 那是选中态语言:selectionRole="navigation",静息不描边 |
必须复用 vs 允许新增
先查表,再动手。已有实现的一律复用,不要另写一份:
| 需求 | 必须用 | 不要做 |
|---|---|---|
| 弹窗 | UiModal |
手写 fixed inset-0 + bg-black/… + 卡片(存量已全部清零,check:surface 规则 C 会拦,别再加) |
| 分组 | UiGroup |
手写 border + bg 的 div |
| 页面标题区 | UiPageHeader |
手写 h2 + p |
| 表单行 | UiFormRow |
手写 label + 间距 |
| 空/加载/错误 | UiEmpty / UiLoading / UiError |
内联手写状态块 |
| 按钮/输入/开关等 | @/components/ui 的 Ui* |
原生 <button>/<input> |
| 提示词编辑 | PromptEditor |
自己拼 textarea |
| 文件上传/排序 | FileUploader / useReorderDrag |
重写拖拽 |
| 音频播放 | @/components/AudioPlayer |
再写一个播放器 |
| 长列表 | react-virtuoso(已是依赖) |
全量 map 渲染上百项 |
新增组件的门槛(三条全满足才允许):
- 在
@/components/ui与本表中确认没有可复用或可扩展的组件 - 优先改成扩展现有组件的枚举变体(像
UiPanel variant/UiGroup titleTone),而不是新开一个组件 - 动手前先向用户说明原因和替代方案,等确认
变体也要克制:新增变体必须是有限枚举,不要开放任意 className 覆盖视觉。
Agent 改 UI 的标准流程
1. 读需求 → 判断落在哪个工作区/页面
2. 整页体检(先横后纵,别直接进组件):
a. 数条带:从窗口顶到内容区横着切了几刀?> 2 条就是骨架问题,先合并再谈别的
b. 顺着 JSX 往上找外层壳:它是不是已经有一条命令带了?有就注入,不要再嵌
c. 数边框层数、看有没有全屏工作面被套成卡片
d. 数实底按钮(应恰好 1 个)、看同组动作是否同档、数分隔线(一条 bar 最多 1 条)
3. 定级:这块内容属于 Region / Group / Divided / Surface / Card 的哪一级
4. 查复用表:需要的组件是否已存在 → 存在就复用
5. 用排版令牌建立层级(标题/正文/元信息),先不加任何容器装饰
6. 只在四条准入条件全中时才用 Card
7. 写代码:颜色用语义类,字号/圆角/阴影/层级用登记令牌
8. 跑自检清单;高影响 UI 再运行真实 Electron 视觉场景并由 Agent 逐张目视截图
9. 完成前按项目规则检查开发环境:未运行就启动,需要重启就只重启当前仓库进程树
第 2 步不能跳。 审查界面时只做组件级检查(每个按钮用没用对 primitive)会漏掉所有骨架问题—— 条带叠条带、工作面套卡片、左边距三个位置,这些单看每一条都合规,丑的是它们摞在一起。 用户说"布局不合理""特别丑"而你只查出了几个 primitive 用错,那基本可以确定第 2 步跳了。
页面完成自检清单
- 从窗口顶到内容区横着切了几刀? > 2 条(命令带 + 从属带)就是骨架错了
- 外层壳是不是已经有命令带了?有的话内层不能再长一条,改成注入
- 有没有为一个按钮单独占的一整行?塞进命令带
- 返回入口放对形态了吗?有页面标题就进
UiPageHeader onBack,别为「← 页面名」单开一条带 - 各条带与其下内容区的横向 padding 是同一个值吗?
- 画布/编辑区/预览区被套成卡片了吗?会随窗口长大的区域不是卡片
- 这个页面有几层边框叠加?> 2 层就回到决策树
- 有没有"比父级更亮"的背景块?有就该是
inset或 bare - 有没有为了填空白而加的卡片/边框/阴影?删掉
- 容器里并列的可点项,静息态还在逐个描边吗?该是
UiOptionButton variant="menu" - 这个表面有几个实底按钮?超过一个就不知道该点哪个
- 同一组里的同级动作是不是同档?有没有为了省宽度把其中一个降档
- 加分隔线了吗?两侧交互语义真的不同吗?一条 bar 最多一条
- 有手写
<svg>吗?路径写死的就是图标,改用 lucide;路径算出来的才是图形 - 用到跨界面的业务概念图标了吗?走
@/core/theme/icons的登记常量,别在调用点自己挑 - 有没有
zinc-*/gray-*/slate-*?改强调色或换主题预设时它们不会跟着动 - 用
accent当文字色了吗?改用text-brand-300;白字要压实心蓝的话底色用bg-brand-500 - 破坏性动作(删除/清空)是不是
variant="primary"?那会抢走主动作的视觉权重,应该静息中性、hover 才出危险色 - 改了
.css文件吗?里面不能有#hex与rgba(数字…),只能rgb(var(--xxx-rgb) / a) - 新加的全局样式/变量放对文件了吗?懒加载的样式表里不能放全局主题变量
- 同一个 className 里有没有两个类抢同一个 CSS 属性?改成互斥三元
- 新面板有没有再叠一层自己的底色?表面应该由外壳统一提供
- 加了模糊吗?只有压在图片/视频/画布上才该加,且只能用
ui-glass/ui-glass-scrim - 动效时长是否落在 150/200/300/500 四档?(缓动已是全局默认,不用每处写)
- 有
setTimeout卸载动画组件吗?那个数字必须和 className 里的duration-*同档 - 过渡的是
opacity/transform吗?别过渡宽高间距,也别用裸transition - 空/加载/错误三态是否都走了
UiEmpty/UiLoading/UiError - 字号是否全部来自登记档位(无
text-[Npx]) - 圆角是否只用了
rounded-lg/xl/full,且内层不大于外层 - 阴影是否只出现在浮层
- z-index 是否用了语义 token
- 同级元素间距是否统一(不要一行
mt-2一行mt-3) - 高频状态(进度/hover/拖拽)是否放在独立 store 而非大列表 state
- 列表超过 ~50 项是否考虑了虚拟化
- 只有改动共享页面骨架、设计令牌或全局界面机制时才按
docs/rules/testing.md运行相关ui:tour -- --only ...;局部界面改动不默认跑全量场景
完成前按风险选择
npm run check:surface
npm run check:colors
npm run check:icons
只运行与本次改动直接相关的专项检查;验证级别和是否追加 lint、类型检查、构建由 docs/rules/testing.md 决定。
改了动效档位或 motion.ts 再补一条(它保证 ms 数值与 duration-* 类不漂移):
npx vitest run src/components/ui/motion.test.ts
只有共享页面骨架、设计令牌、浮层/滚动/溢出机制等高影响界面改动,才先构建并按场景缩小范围运行截图巡检与规则审计:
npm run ui:tour -- --only <受影响场景> --size <受影响尺寸>
npm run check:ui-visual
用户要求“真实运行环境”的视觉审查时,使用 npm run test:reality -- --suite ui|ui-audit --profile real --only ... 的正式 Electron 场景;默认只读,不传 --allow-writes。禁止用浏览器、ego-browser、Chrome、裸 Vite 或 temporary profile 代替。场景断言通过后,Agent 仍必须打开实际截图,检查对齐、裁切、层级、颜色、文案和面板开合状态;DOM 通过不等于视觉通过。巡检结束后退出巡检实例,并恢复或重启当前仓库的 electron:dev。
局部样式、文案或叶子组件改动只运行直接相关的静态检查和精确测试,不追加完整 ui:tour。ui:tour 产出 .ui-tour/index.md 与截图,专门让人检查对齐、留白、视觉权重、hover /
聚焦 / 下拉 / 右键状态;check:ui-visual 只输出规则结论与 .ui-audit/audit.json,
不做像素差异。两者共用场景配置但职责分离,截图差异不作为 CI 门禁。
已知门禁缺口:条带数量还没进自动检查
「页面骨架:横向条带」那节的规则目前全靠人看——check:surface 只看单元素的
border + bg + rounded 组合,check:ui-visual 的十一条规则里也没有数条带的那条。
所以 4 条带的图片编辑页在两个门禁下都是全绿的。
这条其实可判定:在真实 DOM 里沿主轴找连续的 border-bottom 兄弟条带,
数量 > 2 即报错;同理可判「铺满剩余空间且带 border-radius 的容器」。
补这条规则前,看整页时必须人工数一遍,别因为门禁全绿就认为骨架没问题。
check:surface 报三类问题:
[A]手写面板表面 → 改用<UiPanel>[B]同文件多处卡片表面 → 疑似卡片套卡片,内层降级[C]手写弹窗(fixed inset-0+ 黑色遮罩但没用UiModal/AlertDialog)→ 改用UiModal
存量已全部清零,check:surface:strict 已接入 build / electron:build 与 CI,违规会直接让构建失败。改完必须跑一次确认通过。
确需例外时在该行上方加注释 ui-surface-allow 并写明理由;只允许行级豁免,禁止文件级 ui-surface-allow-file(否则该文件将来真正的套娃也会被放行)。
已确认的例外类别:全屏沉浸式媒体查看器(mediaViewer/ 三个 Modal)不套用 UiModal——UiModal 是居中卡片语义,与铺满视口的查看器不匹配。
npm run check:surface(告警式)可用于本地快速查看,构建链路走的是 --strict。
相关规范
- 组件复用与原生标签落点、颜色令牌三处入口:见 docs/rules/frontend-ui.md 与 docs/rules/architecture.md
- 画布节点的行组件拼装:见 skill
canvas-node-builder - 提示词编辑器、文件上传控件:必须复用
PromptEditor/FileUploader,不要重写
Version History
- 19fcc12 Current 2026-08-28 23:13


