mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 22:11:03 +08:00
525 lines
29 KiB
Markdown
525 lines
29 KiB
Markdown
# 界面规范
|
||
|
||
这份文档规定**界面长什么样、为什么**。它约束的是客户端的外观层,不涉及排版
|
||
(书的排版见 `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` |
|