24 KiB
Stash UI 设计系统 · 规格总纲
版本 0.1 · 适用
@stash/ui本文件定义所有组件共同遵守的规则。新增组件、改动组件、评审组件都以下面这些条目为准。
四份文档的分工:
| 文档 | 回答什么问题 | 什么时候看 |
|---|---|---|
| DESIGN.md(本文) | 整体结构与硬性规则:分层、令牌、视觉、代码约定、验收标准 | 动手前看,评审时逐条对照 |
| docs/COMPONENTS.md | 有哪些组件、各自的契约、缺什么组件、缺的时候该做什么 | 想知道「用哪个 / 有没有」时看 |
| docs/ADDING-A-COMPONENT.md | 从零加一个组件的完整流程、模板、示例 | 确认要新增时照着做 |
| docs/ALRIGHT-DECOMPOSITION.md | 参考站拆解:实测令牌、组件尺寸、交互节奏、暗色映射、保留差异 | 怀疑「这个值对不对」时查依据 |
展示站(site/)是这三份文档的运行实例:文档里写的规则,展示页上必须能直接看到。
1. 三条硬约束
违反任意一条,组件不合并。
- 只认令牌
组件 CSS 里的颜色、字号、行高、圆角、阴影、动效、间距,一律
var(--su-*)。 允许的字面量只有四类:0、1px边框、100%、以及组件内声明的局部尺寸变量--xxx-size(见 §5.3)。 - 组件不认识业务
不请求接口、不读路由、不 import 业务类型(
Category/entry之类)。 组件只接收 props,数据由页面喂给它。 - 每个组件都有展示页 展示页只能用真实组件渲染,不能写「演示专用版本」;新增组件必须同时加一节。
2. 分层结构
| 层 | 职责 | 现有组件 | 硬性规则 |
|---|---|---|---|
| L1 原子 | 不可再分的小元素 | Icon Badge Tag Divider |
不负责布局,不带外边距,尺寸由内容或父级决定 |
| L2 控件 | 接收输入 / 触发操作 | Button IconButton Input |
高度只用 --su-control-height-* 或 --su-icon-button-* |
| L3 容器与布局 | 承载内容、排布 | Card Grid Space |
不假设内容形态;不写死宽度;不感知业务 |
| L4 反馈 | 表达状态 | Notice Empty ScrollTop |
不阻塞、不弹层 |
| 页面级(不进库) | 顶栏、侧栏、详情页、业务卡片 | — | 放在应用里,用 L1–L4 拼装 |
依赖方向:
L4 ──→ L3 ──→ L1
│ │
└──────┴──→ L1 L2 ──→ L1
- 只允许「上层依赖下层」,禁止下层 import 上层(例如
Card里不 importEmpty)。 - 组件之间不共享 CSS。除
Card这种明确的容器外,组件不假设自己处在什么容器里。 - 业务组件(分区卡片、应用条目、海报卡……)属于页面层:用库里组件拼,不进库。
3. 令牌(唯一的视觉来源)
完整定义见 src/theme/tokens.css。前缀 --su- 是为了避免和宿主应用变量冲突。
所有取值都来自参考站实测(见 docs/ALRIGHT-DECOMPOSITION.md),不是拍脑袋定的。
3.1 颜色
| 令牌组 | 用途 | 不要用于 |
|---|---|---|
--su-gray-1..8 |
灰阶底座(与参考站一致) | 直接当品牌色用 |
--su-bg-page / --su-bg-card / --su-bg-hover / --su-bg-subtle / --su-bg-light |
页面底、卡片底、悬浮底、浅色块(标签)、浅色块(按钮) | 做强调色 |
--su-bg-notice / --su-bg-danger-soft |
提示条底色 | 卡片底色 |
--su-text-main / -secondary / -sub / -muted / -placeholder |
文字层级(主文 → 次级 → 说明 → 占位) | 互相替代 |
--su-border / --su-border-strong / --su-card-border |
默认细边 / 需要看清的分隔 / 卡片描边(几乎不可见) | 画装饰线 |
--su-color-primary (+-hover -soft) |
主操作、链接、选中 | 大块背景 |
--su-color-danger (+-hover -soft -border) |
危险、错误 | 普通强调 |
--su-color-warning (+-soft -text) / --su-color-orange-dark -light |
提醒、付费角标 | 错误态 |
--su-color-success (+-soft) |
在线、成功 | 主操作 |
--su-gradient-verified / -hot / -paid / -red |
认证角标 / 置顶 / 付费 / 热门 | 大面积背景 |
3.2 间距
--su-space-1..10 = 4 / 8 / 12 / 16 / 20 / 24 / 32 / 40。
只用这些值;需要 2px、6px 这类视觉微调时才允许字面量(且仅限内边距/圆点尺寸)。
3.3 字号与字重
| 令牌 | 值 | 用途 |
|---|---|---|
--su-font-size-xs / --su-line-height-xs |
12 / 16 | 角标(Badge / ItemBadge) |
--su-font-size-sm / --su-line-height-sm |
13 / 20 | 标签、辅助文字 |
--su-font-size-md / --su-line-height-md |
14 / 22 | 控件文字(按钮、输入、表格) |
--su-font-size-base / --su-line-height-base |
16 / 24 | 内容正文、卡片标题(Paragraph / Title level=3) |
--su-font-size-lg / --su-line-height-lg |
18 / 26 | 区块标题、大图标 |
--su-font-size-xl / --su-line-height-xl |
24 / 32 | 页面标题(Title level=1) |
参考站的实际排版是
13/20、14/22、16/24、18/26、body16/24。 组件库把「控件文字」定在 14、把「内容正文」定在 16:控件更紧凑,文章更好读。
字重只有三档:normal 400、bold 500(默认「加粗」)、strong 700
(只给两类:字形符号如认证角标的 ✓,以及 12px 的 ItemBadge 状态角标)。
组件内禁止写 font-weight: 600。
3.4 圆角、阴影、控件尺寸、动效
| 令牌 | 值 | 用途 |
|---|---|---|
--su-radius-xs / sm / md / lg / pill |
4 / 6 / 12 / 24 / 999 | 标签 / 控件 / 卡片 / 大容器 / 胶囊 |
--su-shadow-card |
0 6px 16px 0 rgb(27 26 49 / 1%) |
卡片静止(参考站实测,极轻) |
--su-shadow-hover |
同位置参数,3% | 卡片悬停 |
--su-shadow-pop |
0 8px 24px rgb(27 26 49 / 12%) |
浮层 |
--su-control-height-sm / md |
32 / 36 | 按钮、输入框、未来的 Select |
--su-icon-button-xs / sm / md |
28 / 36 / 44 | 圆形图标按钮(参考站 .btn-icon) |
--su-duration-fast / base |
0.2s / 0.3s | 颜色变化 / 展开淡入 |
--su-ease-in-out / -out / -in |
ease-in-out / ease-out / ease-in | 默认用 -in-out(参考站同款) |
--su-skeleton-shine |
浅色 50% 白 / 深色 8% 白 | 骨架屏扫光 |
--su-focus-ring / -offset |
2px solid primary / 2px |
键盘焦点环 |
3.5 布局与阅读
| 令牌 | 值 | 用途 |
|---|---|---|
--su-layout-width |
1380px | 页面容器最大宽度(Layout contained 与页面共用) |
--su-header-height |
60px | 应用型页头高度 |
--su-radius-cover |
8px | 封面卡圆角(比卡片小一档) |
--su-reading-line-height |
1.9 | 长文正文行高(Paragraph leading="relaxed") |
--su-cover-scrim |
黑 85% → 40% → 透明 | 封面标题的压暗层 |
--su-shadow-card-strong |
2% 卡片阴影 | 需要更明显层次时(如首页分区卡) |
--su-z-header / --su-z-float |
100 / 90 | 顶栏 / 浮动元素 |
4. 视觉规则(拿不准就按这里来)
4.1 尺寸与间距
- 控件高度只有两档;并排出现时必须同档(
Input与Button size="md"都是 36px)。 - 组件不带 margin;元素间距交给
Space或父容器。例外:ScrollTop是浮动元素、Divider自身带间距。 - 卡片内边距:
sm 12/md 20/lg 24,不要出现 18、22 这类值。
4.2 颜色的用法
- 文字三档:主文
text-main、次级text-sub、说明text-muted;不要自造灰。 - 行式条目(列表行、作者行)不加底色、不加边框、不加阴影——这是主题的核心观感,
Card不要套在行上。 - 卡片用「1px 细边 + 极轻阴影」,不要用重阴影或彩色描边制造层级。
4.3 状态矩阵
每个交互组件都要覆盖下表;展示页也要逐个状态摆出来。
| 状态 | 实现 | 备注 |
|---|---|---|
| 默认 | 令牌默认值 | — |
| hover | 主色/危险按钮 opacity: .8;次要按钮换 gray-3 底;图标按钮换 gray-2 底 + 文字加深 |
参考站同款,不做位移、不缩放 |
| active | 与 hover 同色,不再额外变形 | 主题本身没有按压态 |
| disabled | 换浅底 + 弱文字 + cursor: not-allowed(不是整体降透明) |
不改变布局 |
| loading | 同色 spinner 占用图标位 + 自动 disabled + aria-busy |
仅 Button / IconButton |
| focus-visible | 组件自己写 outline: var(--su-focus-ring) + offset |
参考站把焦点样式删了,我们必须补(WCAG) |
| 空 | Empty |
不复用 Notice |
| 错误 | Notice tone="error" |
带 role="alert" |
4.4 动效
- 过渡时长统一用
--su-duration-base(0.3s)+--su-ease-in-out,与参考站一致; 只有 hover 的快速反馈可以用--su-duration-fast(0.2s)。 - 只做颜色 / 背景 / 边框 / 阴影 / 透明度,不做位移、缩放、旋转(加载 spinner 除外)。
- 需要
transition的:Button、IconButton、Card hoverable、Input、ScrollTop、Tag(如加交互)。 - 每个带动效的组件都要有
@media (prefers-reduced-motion: reduce)分支(过渡置 none,spinner 放慢)。
4.5 响应式
断点只有三个档,组件内部处理,页面不要各写一套:
| 断点 | 谁在用 | 行为 |
|---|---|---|
1200px |
Layout |
右栏(aside)从并排变成堆到内容下方 |
1100px |
Layout、Grid |
侧栏堆到内容上方;Grid 降为 2 列 |
820px |
页面(如示例页) | 顶部导航收进抽屉、左侧资料栏隐藏 |
720px |
Grid |
降为 1 列 |
- 组件宽度默认
100%,由容器决定;不写死像素宽度(演示代码除外)。 - 不靠隐藏做适配:
Layout在窄屏下把侧栏/右栏堆叠,内容一律保留;页面如果确实要隐藏(如移动端的资料栏),由页面自己决定。 - 高度用
100dvh优先(100vh兜底),地址栏收起时不会跳动。 - 弹层宽度必须夹住:Modal
calc(100vw - 32px)、Popovermin(320px, calc(100vw - 32px))、Drawermax-width: 100vw。 - 表格/标签栏/分段控件在窄屏横向滚动,不做压缩截断。
4.6 触屏
- 交互只用
click,不依赖 hover;Tooltip的 hover 样式包在@media (hover: hover)里,触屏走:focus-within。 - 可点区域:
xs图标按钮 28×28 是桌面密集场景下限;移动端布局请用sm(36)/md(44)。 - 按钮类加
touch-action: manipulation,去掉双击缩放带来的点击延迟。 - 原生控件(checkbox/radio/switch)都包在
<label>里,点文字也能操作。
现状:组件是响应式的、也能在手机上用(示例页与文档站都在 390 / 820 宽实测过), 但还没有在真机上跑过(iOS Safari 的
dvh、软键盘、backdrop-filter需要真机复核)。 结论性的兼容承诺见 §10「已知坑」与 README 的「浏览器支持」。
5. 代码约定
5.1 文件结构
src/components/
├── Xxx.tsx # 组件 + props 类型(同文件,不拆 types 目录)
└── Xxx.module.css # 只属于这个组件的样式
src/index.ts # 唯一出口:组件 + 类型(export type)
5.2 props 命名
| 类型 | 约定 | 例子 |
|---|---|---|
| 语义变体 | variant |
primary / secondary / ghost / danger |
| 尺寸 | size |
sm / md / lg |
| 颜色语义 | tone |
primary / success / warning / danger / neutral |
| 形状 | shape |
rounded / pill、circle / square |
| 布尔 | 直接用形容词,不加 is/has 前缀 | block loading clearable wrap |
| 透传 | 展开 ...rest 到最外层原生元素 |
原生属性一律可用 |
| 受控 | 值由使用方持有;组件不偷偷维护业务状态 | value + onChange |
| 内部 UI 状态 | 只在确实属于组件自身时保留 | Avatar 的图片加载失败 |
- 事件保持原生语义:
onChange传事件,不要偷偷换成值;需要值由调用方event.target.value。 className/style一律作用在最外层元素;要作用到内部就用具名 props(如inputRef)。- 原生
button必须显式type,默认"button",避免表单里误提交。
5.3 样式写法
- CSS Modules,类名短:
.root、.sm、.primary、.hoverable;修饰类平铺,不嵌套。 - 选择器最多两层;禁止
!important;禁止标签选择器(伪类、::before除外)。 - 组件专属尺寸(头像 24/36/48、角标 18、回到顶部 42)写成组件内局部变量,集中在
.root或尺寸类里:
.sm {
--avatar-size: 24px;
width: var(--avatar-size);
height: var(--avatar-size);
}
- 颜色 / 字号 / 间距永远从全局令牌取,不要复制一份到局部变量。
5.4 无障碍基线
- 装饰性图标
aria-hidden="true";只有图标的控件必须传label(进aria-label与title)。 - 错误提示
role="alert";说明类提示不加 live region,避免每次渲染都被播报。 - 不得移除
focus-visible;隐藏但仍可聚焦的元素要tabIndex={-1}。 - 文案用中文标点;数字与单位之间不加空格(
24px、0.3s)。
6. 验收清单(合并前逐条打勾)
[ ] 1. 组件 CSS 里没有 hex / rgba / 字号 / 间距字面量(只用 --su-*)
[ ] 2. 覆盖默认 / hover / disabled / focus-visible(交互组件再加 loading)
[ ] 3. 透传 ...rest,button 的 type 默认 "button"
[ ] 4. className / style 作用在最外层
[ ] 5. 无外边距(margin),间距交给 Space / 父容器
[ ] 6. 展示页新增一节:变体 / 状态 / props 表 / 代码片段
[ ] 7. 导航锚点、README 清单、COMPONENTS.md 契约三处同步
[ ] 8. npm run typecheck && npm run lint && npm run build 通过
[ ] 9. 截图核对(scripts/screenshot.mjs),无视觉回归
[ ] 10. 没有新增运行时依赖
[ ] 11. 深色主题下不破:不写死白底/黑字假设,颜色全部走令牌
[ ] 12. props 名符合 §5.2;没有出现「业务字段名」(如 categoryId)
[ ] 13. 键盘走一遍:Tab 能看到焦点环,Enter/Space 能触发,Esc 能退出浮层
[ ] 14. 打开「减少动态效果」后没有持续动画(spinner 允许放慢)
7. 深色主题
已实现,组件零改动:所有颜色走语义令牌,深色只覆盖令牌块(tokens.css 末尾)。
用法:在 <html> 上设置 data-scheme:
| 值 | 效果 |
|---|---|
不设置 / light |
浅色 |
dark |
深色 |
auto |
跟随系统(prefers-color-scheme) |
<html lang="zh-CN" data-scheme="dark">
取值来自参考站 body[data-scheme=dark] 的实测映射(见 拆解文档 §8):
- 灰阶整体反相:
gray-5..8变深,文字改用浅色; --su-bg-card→#202022、--su-border→#303033、--su-bg-page→#0f0f11;- 浅色底(
primary-soft/danger-soft/success-soft/warning-soft)透明度提高,否则标签看不见; - 阴影换成更深的黑(深色下浅色阴影没有意义)。
新增组件的深色要求:
- 颜色只用语义令牌(
bg-card/bg-subtle/bg-light/bg-hover/text-*/border-*), 不要直接写--su-gray-*——灰阶在深色下是反相的,直接用会串色; - 展示页用
demo.html?scheme=dark或右上角开关看一眼深色; - 验收清单第 11 条会在深色下逐项检查。
8. 决策记录(为什么是这样)
| 决策 | 结论 | 理由 |
|---|---|---|
| 视觉基准 | 以参考站实测值为准 | 之前按文档里的旧值做(阴影 2%、字阶 10/12/14/16),实测是 1% 和 13/14/16/18,见拆解文档 |
| 自研 vs 直接用 antd | 自研 | 视觉目标是 Alright 主题,用 antd 要重写大半 token;单二进制发布也不想多 300KB+ |
| 样式方案 | CSS Modules | Vite 内置、零依赖、类名可读;Tailwind 会把样式散进 className,emotion 要运行时 |
| 令牌前缀 | --su- |
宿主应用(stash-web)自己有一批 --primary-color 之类变量,必须能共存 |
| 组件边界 | 不收业务组件 | 业务卡片变化快,进库会把组件库拖成第二个页面层 |
| 展示站 | 自建 Vite 站 | 见 README「为什么不直接用 Storybook」 |
| 图标 | iconfont 字形子集 | 主题自带线性图标;按需加字形(流程见 COMPONENTS.md §5) |
| 焦点样式 | 与参考站相反:保留并加粗 | 参考站把 focus 样式删了(box-shadow:none),标了 tab 看不见焦点;组件库必须可访问 |
9. 已知坑(都踩过)
- 展示站要显式 import 主题 CSS:只靠
src/index.ts的副作用引入时,生产构建会把iconfont.css摇掉(dev 正常、build 后图标全空)。 - 库产物的 CSS 内联了图标字体:lib 模式下 Vite 把 woff2 转成 data URI,
dist/stash-ui.css有 80KB+(gzip 56KB),换来「只引一个 CSS」。 - 比例封面不要放进
flex-direction: column的父容器:纵向 flex 会把子项按内容高度压缩,aspect-ratio封面被裁掉一截;用 grid 或 block 容器包一层。 - 并排控件必须同高:
Input与Button size="md"都是--su-control-height-md。 - 展示站里不要用
.demo-*之外的自造类:文档站样式只写在site/app.css,组件样式不外溢。 - 别照着旧文档抄数值:参考站的 token(
--font-size-lg: 16px)和它实际渲染的.text-lg: 18px不是一回事, 以 拆解文档 的实测表为准。
10. 版本与变更
- 当前
0.x:API 仍可能调整,破坏性改动在 COMPONENTS.md 末尾的「变更记录」记一行。 - 组件新增 / 删除 / 重命名,必须同步三处:
src/index.ts、展示页、COMPONENTS.md。
11. 外部风格接入(2026-10-28 记录,讨论中,尚未执行)
场景(之前记错了,这里更正): 开新项目时,使用方会给一张别的网站的截图,风格与 stash / Alright 不一致, 要求「照这个做布局 / 这个组件」。这时必然出现三种情况: 库里没有、参数不够、组件不灵活。
这不是「迁移时补 CSS」的问题,而是库要不要吸收外来设计语言的问题。 如果不设闸门,结果只有两个:要么每个项目各写一套页面 CSS(库永远长不大), 要么把品牌专属样式塞进通用组件(库变成第二个页面层)。
11.1 为什么会撞墙(现有结构的三层限制)
| 层 | 现状 | 外来截图会撞到的地方 |
|---|---|---|
| 颜色 / 字体 | 语义令牌 + CSS 变量,可整体覆盖 | 基本够用:换 --su-color-* / --su-font-* 即可,但前提是页面只用语义令牌 |
| 形态(圆角 / 阴影 / 高度 / 密度) | 一部分是令牌(radius、shadow、control-height),一部分写死在组件里(卡片圆角、按钮高度档位、Tab 高度) | 外来设计常用「32 / 40 / 48 三档高度」「24px 大圆角」「无阴影」——现在只能新增组件参数或覆盖内部类名 |
| 结构 / 交互 | 组件是按 stash 的形态做的(分区卡、封面卡、侧栏菜单) | 外来截图可能是「瀑布流 + 悬浮操作条」「顶部 Tab + 全宽表格」——这是新组件,不是参数问题 |
结论先说:令牌对「配色/字体」够用,对「形态」还不够;组件对「结构」永远需要增量。 所以闸门不是「能不能加」,而是「加在哪一层、以什么粒度加」。
11.2 闸门流程(收到截图后,先停再动手)
截图
→ ① 拆解:把截图拆成「布局骨架 / 组件清单 / 形态值 / 交互」四张表
→ ② 映射:每一项对到库里的组件 + 令牌,标出「完全匹配 / 参数可调 / 缺失」
→ ③ 决策:按 11.3 的矩阵定归属(主题覆盖 / 加参数 / 加新组件 / 页面私有)
→ ④ 评审:把「缺失项 + 最小 API 方案」给使用方确认(此时不动代码)
→ ⑤ 实施:库先行(组件 + 契约 + 展示页 + 令牌),页面后接
→ ⑥ 验收:深浅色、焦点、reduced-motion、移动端、无业务字段
第 ② 步的产物就是一张 「截图 → 组件/令牌」映射表,模板见 docs/STYLE-INTAKE.md。
11.3 决策矩阵(外来样式该落到哪)
| 情况 | 归属 | 例子 |
|---|---|---|
| 只是配色 / 字体不同 | 主题覆盖:新增 themes/<name>.css,只改变量 |
紫色主色、衬线标题 |
| 同结构、形态不同(圆角 / 高度 / 密度) | 加参数或加尺寸档到现有组件 | 按钮 40 / 48 档、卡片 16 圆角 |
| 同结构、交互不同(悬浮操作、展开方式) | 加 variant(受控、可访问) | Tab 改成胶囊 + 下划线、卡片 hover 出操作 |
| 结构不同(新骨架 / 新组合模式) | 新组件,前提是「≥2 处会用」或「属于通用模式」 | 瀑布流、顶部筛选条、全宽表格 |
| 只此一页的装饰(背景图、悬浮搜索、文章排版) | 页面私有 CSS,必须在注释里写清「为什么不是组件」 | stash 的背景图层 |
三条硬边界:
- 不许 fork 库(复制组件到项目里改),要么参数化,要么进库;
- 不许在页面覆盖组件内部类名(
.su-xxx/ hashed class),只能传 props 或改令牌; - 品牌专属值不许进组件默认值:外来项目的紫色不能改
--su-color-primary的默认值,只能在主题文件里覆盖。
11.4 吸收的判定标准(避免库被拖成页面层)
一个能力同时满足下面三条才进库:
- 可复用:至少两个使用方 / 两个页面会用到,或属于公认的通用模式;
- 无业务:不携带字段、不请求数据、不 import 业务类型;
- 可验收:能用 props 表达,且不破坏现有契约(破坏性改动走 COMPONENTS 变更记录)。
11.5 改共享组件必须报兼容性影响
组件不只一个地方用,改之前(以及交付说明里)必须显式列出:
- 调用方清单:哪些页面 / 组件用了它(至少列出仓库内已知调用点);
- 变更性质:新增可选 prop(兼容)/ 改默认值(视觉回归)/ 改或删 prop(破坏性)/ 只改样式(视觉回归);
- 回归范围:哪些展示页 demo、哪些应用页面需要重新截图;
- 迁移方式:破坏性改动要给出替代写法(旧 → 新 的对照),必要时保留一个版本周期。
经验:这次
Layout把「页头」从限宽容器里移出来(页头通栏),属于视觉回归, 虽然 API 没变,但所有用contained的页面都会变——这种改动必须在说明里点出来,而不是静默提交。
11.5 待办(只记录,不执行)
- 令牌分层:现在
--su-*把「原语(灰阶、色板)」和「语义(bg-card、text-sub)」混在一起, 外来主题想换「形态」时没有落点。候选方案:--su-primitive-*(色板/尺度)→--su-*(语义)→ 组件只吃语义;另外把「形态值」抽成--su-shape-*(radius / shadow / control-height / density),让主题能整体换形态。 - 尺寸档位:Button / Input / Avatar 的高度档是否需要补齐 40 / 48,以及是否引入
density="compact|comfortable"。 - 主题文件规范:
themes/<name>.css只允许改变量,禁止写选择器;需要一个 lint 或 review 检查项。 - 外部截图进库的示例:拿一个真实的外来设计走一遍 11.2,验证流程是否可落地。