Files
reader/docs/ui.md
T

525 lines
29 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 界面规范
这份文档规定**界面长什么样、为什么**。它约束的是客户端的外观层,不涉及排版
(书的排版见 `architecture.md` §8 —— 那里的原则相反:对书不动手)。
之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复
改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem,
这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个
可以对照的基准。
---
## 1. 图标
### 1.1 用字体,不用内联 SVG
图标以**字体**形式提供:`web/src/styles/reader-icons.ttf`,37 个字形,
16KB,随 bundle 一起走。
选择字体的理由不是「省事」,而是**一致性可以被强制**:
| | 字体 | 内联 SVG |
| --- | --- | --- |
| 颜色 | 继承 `color` | 每个组件各自写 `fill`/`stroke` |
| 尺寸 | 继承 `font-size`,跟着布局缩放 | 每个组件各自写 `width`/`height` |
| 线宽 | 一个(生成时定死) | 每个字形都可能不同 |
| 新增图标 | 加字符 | 加组件 |
第二列里的每一项都是「单个字形可以漂移」的位置。内联 SVG 集合最常见的失败
就是这个:三十个组件里总有几个的线宽或视觉重量跟其余不一样,而每个单独看都
说得通。字体把字形变成**字符**——它没有自己的几何,也就没有可以漂移的地方。
这条约束还有一层:一份产物两个宿主(H5 与 Android WebView)。字体是 bundle 里的
一个相对路径文件,`file://` 下能加载;组件化的 SVG 集合则意味着每个宿主都要
正确解析组件,而这一步在 WebView 里是额外的风险面。
### 1.2 图标集是生成的
`web/tools/icons/` 下的脚本把**描边路径**编译成字体:
```
paths.mjs 37 条路径,都画在 24 单位网格上,线宽 3
↓ (这是唯一由人编辑的文件)
path.mjs 路径 → 折线
stroke.mjs 折线 → 圆头矩形(每条线段一个)
union.mjs 精确布尔并(Clipper)
build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts
```
重新生成:`npm run icons`。
为什么是「描边 + 布尔并」而不是直接写轮廓:字形要的是**填充区域**,而图标是
**线**。两条相接的线段如果只把各自的轮廓放进去,重叠处会有一条发丝缝——在
16px 下那条缝就是整个图标。所以必须做并集,而且必须是精确的并集。
这一步踩过的坑值得记下来,因为它决定了实现方式:先用「栅格化 + 边界追踪」
做过一版,失败了。原因是所有笔画的宽度都只有一个栅格单元,而**逐单元的边界
规则无法表达「边界在一格宽的图形上左右交替」**——结果是一个圆被追踪成 58 段
碎片。精确布尔没有这种情形。`union.mjs` 的注释里写了这段。
### 1.3 垂直度量:为什么图标能对齐
图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的
**垂直度量**里,而当时所有测试都只读 `cmap` 和字形名。记下来,因为只要再动
一次生成器就会重新踩到。
**约定(生成器与样式表之间的契约):**
- 网格中点(12)就是 `y = 0`,即 em 的中心线;绘制代码用 `(GRID / 2 - y)`。
- `hhea` 与 `OS/2` 的上升部/下降部各为半个 em(512 / -512),也就是
**对称**。对称不是审美:UI 里每个容器都在「居中一个盒子」,只有度量对称时
「居中盒子」和「居中字形」才是同一件事。度量不对称时,两者相差正好是那个
不对称量——这就是当初每一行图标歪的幅度都不一样的原因。
- 每个字形的墨迹必须落在 `±0.5em` 内。生成器会检查,越界直接报错。
- `OS/2` 的 Windows 度量是**无符号**的,且必须包住所有字形;`fsSelection`
打开 `USE_TYPO_METRICS`(否则引擎会去用 Windows 那一对)。
**当初错在哪:** 表格声明上升部 960、下降部 -64(基线靠近 em 底部),而字形是
围绕 em 中心画的。于是每个字形的墨迹都画在它所在盒子**上边界之上**约 0.75em,
而所有布局居中的都是盒子——`menu`、`close`、`folder` 实测偏移一致是 0.496em。
**样式表那一半:**
```css
.icon {
line-height: 1; /* 用字体自己的行盒,而不是自建一个方块 */
display: inline-block;
width: 1em; /* 唯一需要显式给的东西:可预测的前进宽度 */
text-align: center;
vertical-align: middle; /* 非 flex/grid 的行(正文里的行内图标) */
}
```
`line-height: 0` + `height: 1em` 是**错的**:那把盒子锚在基线上、向下一整个
em,而墨迹画在基线周围,于是墨迹贴在盒子上沿、盒子下方全空。居中盒子的容器
把盒子居中了,字形却差了半个 em。`transform` 也不行——它在绘制期移动字形,让
图标占的位置和看起来占的位置不再一致。
代价:`web/test/icons.test.ts` 会读字体的 `hhea` / `OS/2` / `head` 与每个字形的
包围盒来断言上面这些,也会读 `reader.css` 里的 `.icon` 规则。两边都是之前没被
任何断言覆盖的地方。
### 1.4 字符编码
- 码位从 `U+E900` 起,连续分配,位于**私有使用区**。
- 顺序与 `paths.mjs` 中定义的顺序一致。
- `src/ui/icon-names.ts` 由生成器写出,**不要手改**:码位与字体 `cmap` 不一致
时渲染成豆腐块,而类型系统查不出这件事。
- `web/test/icons.test.ts` 直接解析字体的 `cmap` 来核对这张表,并断言字体
**不映射私有使用区之外**的任何码位(否则普通文字在应用了这个字族的元素里
会变成图标)。
### 1.5 无障碍
`Icon` 组件的 `label` 决定语义:
| `label` | 行为 |
| --- | --- |
| 字符串 | `role="img"` + `aria-label`,可访问名就是这个字符串 |
| 省略 / `null` | `aria-hidden="true"`,纯装饰 |
`IconButton` 的 `label` 是**必填**的,图标本身始终 `aria-hidden`。
一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」——
而它旁边可能有五个同样的按钮。
**这是本次改动之前真实存在的问题**:原来的 `IconButton` 接收任意 `children`,
调用方传的是 Unicode 字符(`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁`)。这带来三个后果:
1. 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
2. `🗂` `📁` `📄` `⏮` 这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji
出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;
3. 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。
现在 `IconButton` 的 `icon` 是 `IconName`,即字形的**名字**,
传一个不存在的名字是编译错误。
### 1.6 现在有哪些图标
见 `web/tools/icons/paths.mjs`,或 `npm run icons:sheet` 生成的预览图。
命名按**用途**而非形状:`sliders`(阅读设置)而不是 `knobs`;
`folder-open`(书库管理)而不是 `folder2`。这样同一个语义在改动时不会被
换成另一个形状。
---
## 2. 设计令牌
全部定义在 `src/styles/reader.css` 的 `:root` 里。**界面层不应出现字面量**。
### 2.1 间距:四档
```
--space-1 0.35rem 控件内部的缝隙
--space-2 0.6rem 控件内部的内边距
--space-3 0.9rem 区块之间的间距
--space-4 1.5rem 页面级区块的内边距
```
**只有四档**,这是重点。八档的尺度读起来像精确,用起来像建议:一旦
`1.75rem` 和 `2rem` 同时存在,一半样式表用一个、一半用另一个,而任何单条规则里
都看不出矛盾。四档强制回答「这是哪一档」——这个问题有答案,而「再大一点点」
没有。
### 2.2 字号:按角色
```
--text-title 1.05rem 屏幕标题
--text-body 0.95rem 正文与按钮
--text-label 0.82rem 字段标签、区块标题
--text-meta 0.72rem 标题下方的元信息
```
按角色命名而非按尺寸,因为尺寸会变、角色不会:`--text-meta` 永远是「标题下面
那行」,无论今天是 0.72rem 还是明天 0.75rem。
### 2.3 动效:一个时长、一条曲线
```
--motion-duration 0.18s
--motion-ease cubic-bezier(0.32, 0.72, 0, 1)
```
一个时长,一条曲线,所有过渡都用它们。两个时长和一个时长的问题是同一个:
各自单独看都合理,成对出现就是错的。
`prefers-reduced-motion` 通过**把 `--motion-duration` 折叠成 0.001s** 来满足,
而不是给每条动画各加一个媒体查询。后者的失败是可预期的:下一个新增的动画就是
唯一没加的那条。折叠令牌之后,所有使用令牌的规则(现在是全部)自动被覆盖,
而硬编码自己时长的规则成为**显式的例外**而不是隐式的漏洞。
### 2.4 焦点环
```
--focus-ring 2px solid color-mix(in srgb, var(--reader-accent) 70%, transparent)
--focus-offset 2px
```
**这是本次改动前完全缺失的一层。** 样式表里搜索框有 `:focus-visible` 规则,
而**所有按钮都没有**——书架上一整屏控件,用键盘 Tab 走一遍看不到焦点在哪。
现在是一条规则覆盖一组选择器(`.button`、`.icon-button`、`.chip`……),
而不是每个组件各写一份。新增控件把它加进列表即可,这就是全部工作。
用 `:focus-visible` 而非 `:focus`:鼠标点击按钮不应留下焦点环,键盘 Tab 必须
始终显示。
### 2.5 悬停
统一包在 `@media (hover: hover) and (pointer: fine)` 里。不加这层的话,触屏上
每次点击都会让最后点的控件停在「悬停」外观上——手指抬起就进入 hover,看起来
像坏了,而不是像有反馈。
---
## 3. 布局
### 3.1 安全区
四个 `--safe-*` 令牌在 `:root` 定义一次,各处只引用不直接读 `env()`。
两个理由,第二个才是关键:一个边一个名字,意味着手机、浏览器和 Android 壳不会
各自对「屏幕从哪开始」有略微不同的理解;而且**未定义的自定义属性会让整个
`calc()` 失效并静默丢弃声明**——`padding: calc(0.9rem + var(--safe-left))`
在变量未设置时不是「0.9rem 的内边距」,而是**完全没有内边距**。
### 3.2 一列,封顶
内容列封顶 68rem(`--shelf-measure`),宽屏加的是留白而不是更多列。
1600px 窗口排十一个封面、每个书名十二个字,比手机上的三列**更难扫**——
这与「多出来的宽度是为了好读」正好相反。
### 3.3 触控目标
图标按钮 2.5rem(40px),页脚里 2.1rem(34px)。页脚那两个翻页按钮是唯一
小于 40px 的交互目标,理由是它们最常用、且紧邻一个同样大的兄弟控件;四个
2.5rem 的控件加一个章节标签会超出 360px 手机的宽度。
`SwitchRow` 用按钮而不是复选框:整行是 44px 的命中区,复选框会把命中区缩到
16px 的方块上,拇指按不中。
底线是 28px,由 `npm run ui:review` 逐屏量过(§8):小于这个数的控件在
拇指上不是「偏小」,而是需要瞄准。
### 3.4 悬浮提示,不占位
状态行(`同步中…`、`离线 · 进度待同步`)是**浮在正文上方的药丸**,不是
插在顶栏和正文之间的横条。
这不是审美。横条在 flex 列里占一行,于是每一次状态变化都会把正文往下推一行
再收回来——读者正在读的那句话会跳。一条他没要过的提示挪动他正在读的字,是
这个界面里最不该发生的事。`ui:review` 里有一条断言专门盯着这个:把状态行
从空切到有字再切回来,正文顶部的 y 必须三次相同。
同理,提示不吃指针事件(`pointer-events: none`):一条挡住读者本来要点的
那一下的提示,代价是读者少翻一页。
### 3.5 半屏面板
目录和阅读设置是**从底部升起的半屏面板**,带遮罩和把手,上半屏仍然看得见
正文。
之前的做法是 `inset: 0` 的全屏页,把书完全盖住。全屏对一个*目的地*是对的,
对一个*控件*是错的:选章节、调字号都是对下面那一页的调整,全屏让读者为了做
调整而离开那一页,回来时排版已经变了、他也没有了对照。
三条配套,都不是装饰:
- **遮罩**:没有遮罩的面板读起来像是页面的一部分。它同时也是「点一下关掉」
的命中区,也就是除 ✕ 之外的第二个出口。
- **把手**:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是
*可供性*,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以
它只是视觉线索,真正移动的是面板自己的滚动。
- **高度**:`min(62%, 34rem)`,且不超过 78%。一千二百章的目录想要尽可能多的
空间,十一行的设置不想要比它需要更多的空间。
面板打开时**它拥有点击**:旁边露出的那一条正文不翻页(§7 的优先级)。
### 3.6 事件层级
一次触摸只属于一个目标,最里面那个赢。`ui/gestures.ts` 是这条规则的唯一实现,
顺序是:
1. 读者按到的是控件(链接、按钮、输入、滑块)→ 控件拿走这次手势,不翻页;
2. 面板开着 → 面板拿走(点遮罩=关闭,不是翻页);
3. 否则拖动是滑动翻页;
4. 否则短按落在三段点击区里。
有两个坑是文档级别的,改这块之前必须先知道:
- **必须用 `composedPath()`,不能用 `event.target`。** 书的正文在 shadow root
里,而 shadow 边界会**重定向**事件:点正文里的 `<p>`,监听器看到的 target 是
shadow host。用 `target` 写不出「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式*就是*让 shadow host 滚起来。所以
「可滚动的祖先拿走手势」这条规则必须排除它——否则它吃掉每一个手势,每一次
点击都归属某个滚动容器,翻页层级正确但永远到不了。这一条是 2026-09 那轮
「点击页面没反应」的真正原因。
---
### 3.7 纯文本(TXT)的排版
TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「我们自己给一套完整
默认值」是对的、而不是侵入的地方。EPUB 的默认必须交给作者自己的样式表,
覆盖它是 bug;TXT 没有作者。
两层分工,改任何一层之前先看清是哪一层。**排版全部在客户端,
服务端只负责把章节完整地交出来**——这条边界是这一层最重要的一条规定:
- **服务端**(`server/src/indexer/formats/text.ts`):只做一件事——把**整章
交出来**。`chapter:` 是为流式读取开的窗口,按 `size` 截断是它的功能;
`chapter-full:<n>`(旧名 `chapter-html:<n>`,仍兼容)是给读者整章读的,**不截断**,内容类型是
`text/plain`。它**不再断段、不再提升标题、不再输出 `<p>`**:断段是阅读
决定,服务端做就等于让 windowed 那一份有段落、流式那一份没有,也让读者的
缩进设置变成一次往返。
- **客户端排版**(`web/src/formats/segments.ts`):`splitTextParagraphs` 是
「一段是什么」的**唯一定义**,本地加载的 TXT、windowed 的
`chapter-full:<n>`、流式的 `chunk:` 三条路都走它,所以同一本书不会因为走哪条
传输看起来不一样。它同时负责**去掉段前空格**(`  正文` 里那个全角空格是
「段前空格」的来源,留着就是和读者的缩进**叠加**),以及**章节标题行提升成
`<h3>`**(`splitChapterHeading`,否则正文会变成 `第二章 落雨雨来了。`)。
`ReaderView` 在 `retypePlainText` 里对拿到的章节过这一段逻辑;如果内容带着
历史的 `.txt-body` 标记(旧服务端、旧缓存),就按块读回文本再拆,所以旧
响应不会被当成新响应渲染。
- **客户端样式表**(`web/src/ui/shadow.ts` 的 `TXT_STYLESHEET`):只在
`ReaderView.isPlainText()` 为真时注入,作用域是 `.txt-body`。默认两字符首行
缩进(`2em`,不是 `2rem`——缩进要跟字号缩放)。样式表里**没有任何一条规则读
服务端写的属性**:「不缩进」是同一个自定义属性取 0,而不是第二条要和第一条
保持一致的分支。
- **「这一章是不是纯文本」怎么判定**:靠**声明**,不靠嗅探。`manifest` 的
content item 写 `format: 'html'` → `createStagedDoc` 把它落到
`Section.plainText` → `isPlainText()` 认它。这条链必须完整:服务端现在发的是
**裸字符**,没有 `.txt-body` 包裹,所以「正文里有没有 `txt-body`」这个老办法
对一章节全是纯文本的章节会答「不是」——读者于是看到一本没有段落、没有缩进、
样式表也没挂上的小说,而这看起来像排版偏好而不像 bug。`.txt-body` 标记只留作
最后兜底(旧服务端/旧缓存)。
- **阅读设置**:段落缩进、段间距、编码三行,只在 TXT 上出现;设置面板顶部的
「快捷」一行(字号/缩进/段间距/主题)也是同一批值的另一个入口,一次点击
走一档。前两个是写在 stage 上的自定义属性(`--reader-txt-indent` /
`--reader-txt-para-gap`),所以调整不需要重新注入章节、也不需要请求。
引用保持 `chapter:<n>` 不变,`format: 'html'` 字段告诉客户端取哪一份
(`renditionRef` 据此把它换成不截断的 `chapter-full:<n>`)。`href` 同时是
**阅读位置的标识**,改它会把每个现有 TXT 读者打回第一章。
---
### 3.8 沉浸式阅读
**顶栏和底栏是一个状态,不是一个状态乘以二。** 读者要收起导航就是要看到正文,
上面收了下面没收,就是读者报的「只收起了一半」——底部那条一直压着每一页的最后
两行。所以两条栏由 `.reader-screen` 上的一个 `data-chrome` 属性一起开关,写法上
不可能只藏一半,动效、时长、缓动也完全一致(`--motion-duration` + 同一缓动)。
三条实现约束,改这块之前先知道:
- **收起的不是浮层,是占位。** 两条栏是正常流里的 band,收起时 `height` 归零、
`transform` 滑出、`visibility: hidden`(不是 `display: none`——那没有东西可以
做动画,也让「隐藏」和「不可点」两件事分家)。归零的高度由 `flex: 1 1 auto`
的 stage 立刻拿走,所以页面是**长**进这块空间,而不是先跳一下再填上。
- **`visibility` 在动画结束后才生效**(`transition: visibility 0s linear
var(--motion-duration)`):滑出的过程中标题还在,动画走完它就既看不见也点不
到了。少写这一行,读者能点到一个看不见的返回按钮。
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
`data-chrome` 属性开关,和顶栏底栏共用一套动效。读者最常用的四个动作(目录 /
主题 / 字号 / 设置)一步可达,而且按钮住在正文的版心旁边(宽屏时用
`--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:那四个动作
和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在正文右
边缘的按钮,就不是读者要的「只剩正文」。
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
省掉的那次交互。两个读数 `pointer-events: none`(它们是读数不是控件,点它们
必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。
顶栏与底栏各自的形态(对应参照稿):
- **顶栏**是一排「图标在上、文字在下」的等宽按钮(`.top-button`),不是「标题 +
返回箭头」。参照稿里它是读者**扫**着用的一行工具栏,每个控件一样重、一样一步
可达。书的名字曾经写在这里,现在它属于 `.reading-indicator`——读者找标题时
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
一个点了没处的按钮比少一个按钮更糟。
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的**章节内页数**
滑杆加页码读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的
进度条——条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;
`input[type=range]` 一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、
`aria-valuenow`)都是原生的,不用自己实现一遍。
- **滑杆量的是章节内的页**(`min=1`、`max=chapterPages`),不是全书百分比。
滑杆旁边写的是「第 3/9 页」,两者必须来自同一个测量,否则一次拖动会落在
读数从没提过的位置上——这正是最初「滑杆拖到中间却跳到第 40 章」的成因。
全书进度是下面那行「阅读进度:xx%」的事,那行**只报告**,不承载手势。
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
`ReaderView.seekPageInChapter`)。
手势上,**中间三分之一在两个方向上都是开关**;左右三分之一只在 chrome 已经
收起时才充当「叫回来」的出口(§3.6 的优先级第 2 条)。收起状态下任何一条区都
是出口,是因为读者此时没有别的出路;已经显示时还抢左右三分之一,就是把「翻页
没反应」做成常态。
---
## 4. 文案
- **中文,句末不加句号**(`我的书架`、`正在刷新`)。界面文本不是段落。
- **说清楚发生了什么,而不是说「出错了」。** `已更新 2 本`、
`这一章暂时无法跳转`、`连不上这个地址。确认服务端已启动,且地址包含端口。`
- **不承诺不能兑现的事。** 只读挂载上不显示上传按钮(用 `hidden` 而非不渲染,
见 §5),而不是显示一个按了会 403 的按钮。
- **空状态给一条出路。** 空书库不是「这里什么都没有」,而是「打开书库管理 /
刷新」两个按钮;搜不到结果是「清除搜索」。
---
## 5. 状态的表达
| 状态 | 用法 | 反例 |
| --- | --- | --- |
| 禁用 | `disabled` + `opacity: 0.55` | 隐藏按钮,让读者以为功能不存在 |
| 不可用(权限) | `hidden` | 隐藏整个区块,让读者不知道这里能管理书库 |
| 忙碌 | 按钮文案变「正在切换…」,控件 `disabled` | 只留一个转圈,读者不知道在等什么 |
| 无能力 | 不渲染该行 | 显示一个按了没反应的滑块 |
最后一行值得展开:朗读设置面板在 HTTP 引擎下**不显示音调那一行**,因为
`<audio>` 根本没有音调。而不是显示一个拖动无效的滑块。
`hidden` 而非不渲染的那一条同样是刻意的:能做的和不能做的**区别**本身就是
信息。全部藏起来,读者学到的是「这个应用的按钮时有时无」。
---
## 6. 暗色与主题
三套主题:`light` / `sepia` / `dark`,通过 `:root` 上的
`data-theme` 切换调色板变量。
- 主题只改**界面**的颜色。书的颜色由书自己决定——一个硬编码
`color: #333` 的段落,在暗色主题下保持它自己的颜色。这是正确的取舍:
暗色主题悄悄破坏一本印刷取向的书的对比度是缺陷,而一本书在「暗色」背景上
显示深色文字,是读者**能看见并自行判断**的。
- 亮度是 `mix-blend-mode: multiply` 的覆盖层,不是调色板。换主题应该保留
纸与字的对比关系;直接改颜色会把文字一起压暗——那正是「亮度」滑杆最常见的
失败方式。
- 新增颜色一律走 `:root` 变量。样式表里现存三个状态色
(`#4a8f5f`/`#c08a2e`/`#b6453a`)和一处危险色(`#c0392b`)是**已知例外**,
它们是语义色不是主题色,但应当在下次改动时提升为令牌。
---
## 7. 端到端测试要做 UI 评审,不能只看功能
功能测试问的是「点了会不会变」,UI 评审问的是「变了之后能不能用」。两者可以
同时给出相反的回答,而且这在真实项目里是常态——下面每一条都曾在功能全绿的同时
是错的:
| 曾经的形态 | 功能测试怎么看 | 读者怎么看 |
| --- | --- | --- |
| 目录是 `inset: 0` 全屏页 | ✅ 点开有面板,点条目会跳转 | 为了选一章而离开那一页 |
| 翻页步长 0.9 屏 | ✅ `scrollTop` 变大了 | 每次翻页留一条上一页的尾巴 |
| 页数按内容高度算 | ✅ 画出了 `本章 x/y 页` | 翻到最后一页时数字不认账 |
| 状态行是 flex 里的一行 | ✅ 文案正确渲染 | 每次同步都把他正在读的那句推走 |
| 图标按钮自己拼的 | ✅ 点击有反应 | 图标和文字差半个字高 |
| 底栏滑杆量的是全书百分比 | ✅ 拖了会动、页码也变了 | 想翻到本章第 7 页,被带到了第 40 章 |
| 快捷按钮列只在沉浸态出现 | ✅ 收起工具栏后按钮还在 | 收起后正文右边仍压着一列浮层 |
所以 `npm run ui:review` 做两件事,两件都要:
1. **截图**(`docs/ui-review/*.png`)。这一步没有断言可以替代。「只是丑」不是
测试失败,但它是缺陷。截图交给评审的人看。
2. **量尺寸**(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人
不在流水线里。当前量的是:顶栏/底栏/快捷列三条 band 一起收放、收起时正文占满
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
它跑在**真实的生产产物**上:`npm run build` 之后由 `tools/ui-review/server.mjs`
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
组件故事会喂进手写的 state,而上面那些缺陷全都发生在「屏幕对真实响应」的时候。
断言挂在 `client-ui-review` 流水线上,只在界面相关文件变化时触发。
---
## 8. 改动这份界面时的检查单
1. 新颜色 / 新间距 / 新时长 → 先看 `:root` 里有没有,没有就**加令牌**,
不要在规则里写字面量。
2. 新图标 → 加一条路径到 `tools/icons/paths.mjs`,跑 `npm run icons`,
用 `IconButton` 引用名字。
3. 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
4. 新动画 → 用 `--motion-duration` 和 `--motion-ease`,不要自己写时长。
5. 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
6. 图标按钮 → `label` 必填,且这条 label 是**这个按钮自己的作用**,
不是它所在的区块的名字。
7. 图标 + 文字并排 → 用 `IconTextButton`,不要自己拼一个 `<button>` 加一个
`<span class="icon">`;图标加文字正是最容易歪的组合,而它每次歪的方式都
一样(字形落到文字基线上)。
8. 动图标相关的东西 → 先读 §1.3。图标对齐是字体度量和 CSS 两半一起决定的,
只改一半会得到一个更小的偏移,而不是没有偏移。
9. **动布局 → 跑 `npm run ui:review`,并且看截图。** 断言只覆盖已经量过的那些
规则;新缺陷的第一道防线是眼睛。
10. **加一条能一直看着的断言**,如果这条规则是能量出来的。下一个人不会看到
这次截图。
---
## 9. 相关文件
| 路径 | 内容 |
| --- | --- |
| `web/src/ui/icon.tsx` | `Icon` 组件 |
| `web/src/ui/icon-names.ts` | 码位表(生成) |
| `web/src/styles/reader-icons.ttf` | 字体(生成) |
| `web/tools/icons/` | 生成器 |
| `web/test/icons.test.ts` | 字体度量、字形包围盒、码位表与 `.icon` 规则的契约 |
| `web/src/ui/toolkit.tsx` | `IconButton` / `Button` / `Segmented` / `SwitchRow` / `Scrim` |
| `web/src/ui/gestures.ts` | 手势与事件层级(§3.6) |
| `web/src/ui/reader-chrome.tsx` | 阅读页的顶栏/页脚/面板/朗读条/沉浸式按钮列 |
| `web/src/ui/shadow.ts` | 书内容 shadow root;含纯文本排版表(§3.7) |
| `web/src/formats/segments.ts` | 纯文本分段(「一段是什么」的唯一定义,§3.7) |
| `web/src/ui/reader-view.ts` | 章节渲染与位置;`retypePlainText` 是客户端排版落地处(§3.7) |
| `web/test/gestures.test.ts` | 事件层级的优先级契约 |
| `web/test/reader-paging.test.ts` | 翻页的页序号算术(含子像素取整) |
| `web/tools/ui-review/` | UI 评审:截图 + 尺寸断言(§7) |
| `docs/ui-review/` | 评审截图与 `report.json`(生成,随 PR 一起看) |
| `web/src/styles/reader.css` | 令牌、组件样式、交互状态、`prefers-reduced-motion` |