把 #48 的朗读条与快捷列改动合进本分支,两处冲突都在 `web/src/ui/reader-chrome.tsx`,都取 main 那一侧: - 快捷列:main 删掉了 目录 / 行距 / 页边距 / 设置 四个入口(#48 的报告), 本分支新增的 `menu` / `line-height` / `indent` / `sliders` 随之作废。 留下 主题 / 字号 / 听书 三项,字号改用本分支新词汇表的 `text-size`, 听书改用 `volume`。 - 朗读条:main 拆成两行(控制行 + 进度行)并加了 `tts-stop`。本分支的单行 版本丢弃。图标名按本分支的词汇表回落:`backward-step`/`forward-step` → `step-backward`/`step-forward`,`xmark` → `close`。 为让两边都能编译,`icon-names.ts` 增加一层别名表 `ICON_ALIASES` (`font`/`volume-high`/`bars`/`gear`/`xmark`/`magnifying-glass`/`books` → 已画的码点),另导出 `ICON_CODEPOINT_TABLE`。别名是**拼写**不是字形: `icons.test.ts` 的借用名单断言只管 `paths.mjs`,新增两条断言钉住别名既 落在被否名单里、又必须指向字体真画了的码点;码点唯一性改按去重后的集合计。
59 KiB
界面规范
这份文档规定界面长什么样、为什么。它约束的是客户端的外观层,不涉及排版
(书的排版见 architecture.md §8 —— 那里的原则相反:对书不动手)。
之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复 改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem, 这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个 可以对照的基准。
1. 图标
1.1 用字体,不用内联 SVG
图标以字体形式提供:web/src/styles/reader-icons.ttf,41 个字形,
18KB,随 bundle 一起走。
选择字体的理由不是「省事」,而是一致性可以被强制:
| 字体 | 内联 SVG | |
|---|---|---|
| 颜色 | 继承 color |
每个组件各自写 fill/stroke |
| 尺寸 | 继承 font-size,跟着布局缩放 |
每个组件各自写 width/height |
| 线宽 | 一个(生成时定死) | 每个字形都可能不同 |
| 新增图标 | 加字符 | 加组件 |
第二列里的每一项都是「单个字形可以漂移」的位置。内联 SVG 集合最常见的失败 就是这个:三十个组件里总有几个的线宽或视觉重量跟其余不一样,而每个单独看都 说得通。字体把字形变成字符——它没有自己的几何,也就没有可以漂移的地方。
这条约束还有一层:一份产物两个宿主(H5 与 Android WebView)。字体是 bundle 里的
一个相对路径文件,file:// 下能加载;组件化的 SVG 集合则意味着每个宿主都要
正确解析组件,而这一步在 WebView 里是额外的风险面。
1.2 字形名是这个项目自己的,而且不许借别人的
图标的名字是界面层读的那套英语词汇,取自这个代码库和整个 Web 本来就说的那些词:
menu、search、close、settings、library、shelf。没有一个是「为了不一样
而不一样」编出来的——菜单就叫 menu 而不是 hamburger-glyph,写过前端的人看见
arrow-left 就知道它画什么。
曾经借过 Font Awesome 6 的类名,现在改回来了,理由值得留着,因为它正好是 「借名字」这个想法反着咬人的地方:
| 想法 | 实际发生的事 |
|---|---|
| 借一套开源图标的名字,「放大镜」在全项目就只有一种拼法 | 借来的名字同时是对别人画的图形的声明。magnifying-glass 是 Font Awesome 的某个字形,熟悉那套图标的人打开 paths.mjs,期待看到那一个,看到的是我们的——每一个借来的名字都是这样 |
| 名字只是标签,几何是自己的 | 名字一旦是标签,就不该是别人家的标签。几何从来不是 Font Awesome 的,名字也不该是(见 §1.2 末段) |
所以现在有两条硬约束,test/icons.test.ts 都钉住了:
- 没有借来的名字。 一张被否掉的名单(
bars、magnifying-glass、xmark、gear、table-columns……)在测试里,改回去会失败。 - 名字就是它画的东西,而且整份词汇表在测试里逐字列出:加一个图标是一次要 想清楚的动作,而不是一个恰好编译得过的字符串。
命名按用途而非形状:sliders(阅读设置)而不是 knobs;按读者看见的东西
而非实现:settings 而不是 gear-teeth-12。
1.3 图标集是生成的
web/tools/icons/ 下的脚本把描边路径编译成字体:
paths.mjs 41 条路径,都画在 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.4 垂直度量:为什么图标能对齐
图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的
垂直度量里,而当时所有测试都只读 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。
样式表那一半:
.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 规则。两边都是之前没被
任何断言覆盖的地方。
但度量对了 ≠ 一定会对齐。第三半:盒子对了,行盒不一定在对的地方。 #40 报的「书架页面的搜索按钮没有垂直对齐」就是这么来的,而它逃过了所有度量断言, 因为度量这次是对的。
/* 旧写法:盒子居中了,墨迹没有 */
.search-glyph { position: absolute; inset-block: 0; margin-block: auto; }
position: absolute 会把 display 变成 block。规则里的盒子确实被居中了
(实测 75.98..120.19 落在一个 75.98..120.19 的输入框里,正中),但块级盒不会
把行盒在垂直方向居中,它把行盒放在顶部。行盒只有 1em(line-height: 1),
于是 1.05rem 的墨迹中线落在 y=84,而输入框中线是 y=98:高了 14px。
修法是用布局系统说这件事,而不是用 transform:
.search-glyph { position: absolute; inset-block: 0; margin-block: auto;
display: grid; place-content: center; }
place-content: center 居中行盒,配合 §1.4 的对称度量就是居中墨迹——
和 .icon-button 同一套写法,所以「盒子里的字形」在这个样式表里只有一个答案。
top: 50%; transform: translateY(-50%) 也能修好,但更差:要在每个调用点重复、
漏掉一个就再犯一次,而且它把偏移变成 transform 的事实,输入框高度一变就要跟着改。
量的是墨迹,不是盒子。 run.mjs 用 Range 取字形的墨迹中线断言它落在
输入框中线 ±2px 内(实测偏差 0.4px)。断言盒子是没用的:盒子在缺陷存在时就是对的,
这正是它活过一轮布局评审的原因。
1.5 字符编码
- 码位从
U+E900起,连续分配,位于私有使用区。 - 顺序与
paths.mjs中定义的顺序一致。 src/ui/icon-names.ts由生成器写出,不要手改:码位与字体cmap不一致 时渲染成豆腐块,而类型系统查不出这件事。web/test/icons.test.ts直接解析字体的cmap来核对这张表,并断言字体 不映射私有使用区之外的任何码位(否则普通文字在应用了这个字族的元素里 会变成图标)。
1.6 无障碍
Icon 组件的 label 决定语义:
label |
行为 |
|---|---|
| 字符串 | role="img" + aria-label,可访问名就是这个字符串 |
省略 / null |
aria-hidden="true",纯装饰 |
IconButton 的 label 是必填的,图标本身始终 aria-hidden。
一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」——
而它旁边可能有五个同样的按钮。
这是本次改动之前真实存在的问题:原来的 IconButton 接收任意 children,
调用方传的是 Unicode 字符(☰ ⚙ ✕ ⟨ ⟩ + 🗂 📁)。这带来三个后果:
- 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
🗂📁📄⏮这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji 出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;- 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。
现在 IconButton 的 icon 是 IconName,即字形的名字,
传一个不存在的名字是编译错误。
1.7 现在有哪些图标
见 web/tools/icons/paths.mjs,41 个字形。名字是这个项目自己的英语词汇(§1.2),
而且是全集的、逐字列出的——test/icons.test.ts 里 VOCABULARY 那张表就是
允许出现的全部名字,所以「这个语义用哪个字形」在改动时不会漂到另一个形状上,
也不会悄悄多出一个没人审过的形状。
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)。翻页器的页码是 2rem(32px): 它是读者会反复瞄准的控件,而 0.72rem 的数字装在一个 16px 的盒子里是需要用眼睛 而不是用拇指去点的。页脚那两个翻页按钮是唯一 小于 40px 的交互目标,理由是它们最常用、且紧邻一个同样大的兄弟控件;四个 2.5rem 的控件加一个章节标签会超出 360px 手机的宽度。
SwitchRow 用按钮而不是复选框:整行是 44px 的命中区,复选框会把命中区缩到
16px 的方块上,拇指按不中。
底线是 28px,由 npm run ui:review 逐屏量过(§8):小于这个数的控件在
拇指上不是「偏小」,而是需要瞄准。
3.4 两个列表屏:书架与书库
这是两个屏幕,不是两个标签页。 书架回答「我能读什么」,书库回答「服务端有什么」,
两者的差别不是同一份数据的两个视图——一条是 books 表(带用户自己的上下架状态),
一条是挂载目录本身。所以它们是两个路由:
#/shelf[/<页>] 书架
#/library[/files][/<路径>][/<页>][?q=<搜索>] 书库(两半,见 §3.4.1)
做成同一个屏幕的两个标签只会更省事一点点,而会让三件事同时变坏:
- 返回键。 标签切换会进同一段历史轨迹,于是 Back 变成「退回上一个标签」而不是 离开这个屏幕。书库的面包屑本来是「走进去的文件夹用面包屑退,不用返回键退」, 加一个标签进去,Back 就得同时扮演两个角色。
- 链接。
#/library/科幻是能给别人的;「书架的第 3 页」也是(#/shelf/3)。 - 一屏一个标题。 参照稿里两个屏幕各有自己的标题与右侧入口,而标签栏要占一行。
3.4.1 书库自己是两个屏幕(不是一个屏幕的两个页面)
这一版改了上一版。 上一版把书库做成「一个屏幕、两个页面、一个页签」,理由是 「标签进了历史就是 Back 走标签而不是离开书库」。那一版被 #40 退回来了,理由更大:
书库预览页和管理页拆开,两者列表显示逻辑不一样。预览页需要显示搜索框,类似一个 商城的布局。而书库管理页面只有管理员才能看到使用。
一个页面装不下两个列表,因为它们根本不是同一份数据的两种画法:
| 浏览(读者那一半) | 文件管理(管理员那一半) | |
|---|---|---|
| 问的问题 | 「这里有什么可读的」 | 「我的书在哪,让我挪一下」 |
| 端点 | /api/v1/books?path=…&search=… |
/api/v1/library/browse?path=… |
| 一条是什么 | 一本书(书名、封面、作者) | 一个文件(文件名、大小、扫描标记) |
| 布局 | 商城:搜索框在最上,封面网格铺满窗口 | 清点:面包屑 + 文件行 + 批量条 |
| 一页多少 | 60 本 | 200 条 |
| 谁能用 | 任何登录用户 | 只有管理员 |
做成一个屏幕的两个页面会同时逼出三句假话:
- 一个头部服务两个列表。 浏览页的头部要有搜索框,文件页的头部要有面包屑、 上传和新建;共用之后两边都得让步,让步的产物就是上一版里那个既不像商城也不像 文件管理器的头部。
- 一条可达性规则。
#/library/科幻打开的是封面,而文件管理器离它只有一次 「页签」点击——也就是每一个能发给别人的链接都通向一个可以删书的页面。 - 一个页码。 两边分页的列表和每页条数都不同,切换必须重置页码,于是「第 4 页」 按哪个页面在看会是两个意思。
所以是两个路由:
#/library[/files][/<路径>][/<页>][?q=<搜索>]
#/library/科幻是浏览页。没有files段,因为这是读者要的页面,也是 别人发给你的那个链接。搜索是?q=:它是自由文本、可以没有、并且落在路径之外 ——这样文件夹里叫什么都无所谓。#/library/files/科幻是文件管理页。只有管理员进得来,而这是服务端的规矩 (每一行都写磁盘);客户端这一层是「不给他建那个页面」,见 §3.4.4。
两个路由的代价是:切换从「页签」变成导航,于是 Back 回到你刚才在的那一半。 对两个不同受众的屏幕来说,这正是对的读法,也是这次报告要的。
预览页要的是「这个文件夹里的书」,而那是 /api/v1/books 的形状,所以它问的是
/api/v1/books?path=<目录>&search=<词>。做一个新端点、或者把 /library/browse 的行
渲染成网格,都不行:browse 的一条是文件名,不是书名,而且没有封面——那会是一屏
「把文件名画在书名位置」的格子。path 是对 rel_path 的前缀匹配,
带上分隔符(dir/% 而不是 %dir%),否则「筛选科幻」会把「筛选科学」一起拖进来;
% 和 _ 是合法文件名字符也是 LIKE 通配符,所以两个都转义。
3.4.1.1 浏览页是商城式的
「类似一个商城的布局」落到三条具体规则上:
- 搜索框在最上面,是这一页自己的,筛的是这个文件夹(面包屑是范围)。 它和文件夹、页码一起进 URL,所以一次筛选是一个链接。
- 网格不被
--shelf-measure截住。 书架要截,因为书架是读者自己那一柜、 按他选的大小铺,一屏十一张封面是墙不是列表。书库是服务端的目录,大小是磁盘的 事实,宽屏上读者想要的是多看见一些而不是多两边的留白。所以这一页是auto-fill无上限,并且封面尺寸阶梯比书架高一档——去掉上限以后用书架的阶梯 会立刻退化成一屏 130px 的缩略图,正是阶梯存在的理由。 - 每张封面一个动作。 「加入书架」只画在不在书架上的那几本上(§3.5.1)。
3.4.1.2 搜索框两边的留白
「书架的搜索框两边需要增加 margin」是一条经验,但它说的是一件比「加个 margin」更 具体的事:字段原来和封面列同宽,于是胶囊的 1px 边框、以及清除按钮那个 40px 的 圆,都落在封面的同一条竖线上——在那个距离上,边框读起来像封面的一部分,清除按钮 看起来像在第一张封面里面。
所以:
- 留白是一个 token(
--shelf-search-inset),两边相等,否则字段会整体偏移, 那是「什么都没做错但看着歪」的另一种成因; - 它声明在
:root而不是.shelf上。这一条是修出来的:token 原来挂在.shelf上,而书库的搜索框不在.shelf里,padding-inline: var(--undefined)就是padding-inline: 0——留白静默地不出现,跑了一整轮评审 才被「字段左 0px / 右 0px」这句测量抓住; - 输入框
type=search自带的那颗清除按钮要关掉(::-webkit-search-cancel-button), 否则一屏上有两个清除控件,其中一个是浏览器自己的蓝色小叉。
「加入书架」只在能说出路径的书上画。 端点收的是路径,而 BookDto 没有路径:
书名和磁盘上的文件之间唯一的联系是去掉扩展名的文件名。文件列表是分页的,
所以文件夹第二页上、文件在第一页的那本书是没有路径可报的——画一个按钮、
按下去才说「找不到路径」,不如不画。这是三态,卡片三种都要读:
| 情况 | 画什么 |
|---|---|
文件在本页,shelfState: 'on' |
不画(已经在了) |
文件在本页,shelfState: 'off' |
画「加入书架」——这一页存在的理由 |
文件不在本页 / shelfState: null |
不画(报不出路径,服务端也会拒绝) |
网格上面那行(「这一页有 N 本还不在书架上」)数的也是这一页的第二类, 而不是整个文件夹:一行说 12 本、下面只有一个角标,是一句和自己画的东西矛盾的话。
3.4.2 书架的排序:默认「最近阅读」,键是阅读时间
书架有四个排序:最近阅读(默认)、最近入库、书名、作者。 两个「最近」问的是两个不同的时间——读者上一次读这本书,和这本书进这个库—— 而这个区别曾经被一个「最近更新」盖住了,它问的是第三个时间(文件改动时间)。
三件事因此成立:
- 默认是「最近阅读」。 打开应用要回答的问题是「我上次读到哪」,而它一天问四十次; 「我刚放了什么进来」是扫描完那一次才问的问题。把后者当默认, 等于每次打开都先回答一个没人问的问题。
- 「最近入库」留着。 它差点被删掉,而留着是对的:它就是上面那个「扫描完才问」的问题, 而那时候「书名/作者」都不是读者想要的那个顺序。
- 存储里的
updated向前折叠成added。 名字换过(它本来就是「什么时候进的库」), 而一个存在本地、界面里选不到、服务端也认不出的排序值,会变成「设置被忽略了」。
排序键的归属不一样,这是最要紧的一条:
added/title/author是服务端的键,所以它们由服务端排序、服务端分页。recent服务端没有:阅读时间在 progress 行上,不在书行上。所以它由客户端排, 而且必须先读一整段书架——把一页六十本按阅读时间排完画在一个写着「第 1 / 34 页」 的翻页器下面,是对另外三十三页的谎。所以这个排序一次读多页(上限 500 本), 在本地排完,再切出自己那一页;超出的尾巴在副标题里说出来, 而不是静默少掉几本。
同一个 SortKey 还决定了「离线时怎么画」:服务端排序离线时画的是镜像里的全部,
客户端排序离线时画的是同一段切片——两边的页数才对得上同一个翻页器。
3.4.4 文件管理页只有管理员能用
文件管理页的每一个控件都写服务端的磁盘:上传、重命名、移动、删除。所以它只给管理员, 而且是两层,不是一层:
- 入口。 书架的「书库」按钮打开的是浏览页——那是一个读者的按钮。文件管理页 的入口只在浏览页的头部,写着「文件管理」,是一个带文字的控件而不是一个裸图标: 读者需要知道按下去会离开书、进入一个能删东西的页面。
- 路由。 判定在
App.render,不在按钮上。URL 是用户输入——它从书签、从聊天 消息、从手写的链接过来——藏起一个按钮只是对读者的提示,render里那一句才是 检查。不是管理员却打开#/library/files/科幻,落到的是同一个文件夹的浏览页, 而不是一个错误页:书库是允许他去的地方,链接说的那个文件夹就是链接真正要说的东西。
角色在进应用时从 GET /api/v1/auth/me 读一次,没读到之前一律按非管理员算:
「还没问」不能是一段「成员也能打开删文件那个页面」的时间窗。后端仍然是每一行的
权限判定(那个页面上的每个端点都会拒绝),客户端这一层只保证不给他建那个页面。
3.5 翻页,而不是无限滚动
两个列表都分页,页是 URL 的一部分,翻页器在列表下方。
书架原来是在滚到底时追加一批(loadMore)。它有两个问题,而且都不是「体验」问题:
列表没有读者能看见的长度,位置也回不去——关掉再打开从第一屏开始,因为「我在第三屏」
只存在于一个滚动偏移里,而重绘会把它扔掉。两千本书是一个地方,地方需要有位置。
#/shelf/3 就是一个位置:可以返回、可以前进、可以收藏、可以发给别人。
页数是服务端算的,页是服务端切的:
- 书架:
/api/v1/books?page=N&pageSize=60。60 恰好等于原来一次追加的量, 所以一个本来就不卡的书库仍然一个请求铺满一屏。 - 书库:
/api/v1/library/browse?path=…&page=N,一页 200 条。书库面对的是文件系统, 一个放四千张扫描件的漫画目录是真实存在的,把四千行发给浏览器丢掉 3940 行 是那个让屏幕显得坏掉的请求。上界BROWSE_PAGE_SIZE_MAX保证调用方换不来那个请求。
页码超界在响应之后夹,不在请求之前猜。 唯一知道一共几页的是 total,而它随响应回来——
读者在第三页时书库缩到两页,问第九页的答案是一个空列表而不是错误;不夹的话读者得到
一张空网格、上面一个页码,没有任何东西告诉他列表其实没问题。夹完再问一次最后一页,
并且把 URL 一起改写,否则 Back 会回到那个被赶走的空页。
翻页是导航,不是本地改数字。 屏幕不自己改页码:它上报意图,shell 写 URL,
URL 再回落到 showPage(page)。这一趟往返就是页能活过 Back、Forward 和刷新的全部原因。
翻页器也照此画:aria-current="page" 标当前页,两端禁用而不隐藏,页码窗口是
「首、尾、以及当前页的邻居」——50 页在手机上是一排需要读而不是按的按钮。
3.5.1 上架与下架:书库必须说得出「这本书为什么不在书架上」
书库页存在的理由就是回答「我的书去哪了」,所以它必须能说出「这本书不在你的书架上」, 并且能把它放回去。两件事曾经都没有,那就是 #40 报的「书库的书不再自动加入书架, 需要手动加入」。它是三层各缺一块:
add没有清掉hidden。 服务端把add写成了INSERT OR IGNORE, 而被拿掉过的书已经有一行user_books(remove为了可逆写下的hidden = 1), 于是OR IGNORE跳过它、书继续不在书架上。读者选中它、点「加入书架」、 什么都没发生。可逆性只存在于表结构里而不存在于端点里,就不是可逆性。 现在add/unhide是 upsert:不存在则插入,存在则清标志。- 界面没有「上架」这个动作。 行菜单和批量条都只调用
remove, 一个已经不在书架上的书没有任何控件说得出不同的话。 现在行菜单按shelfState给对应方向,批量条两个方向都给。 - 书库不显示书架状态。
BrowseEntry.shelfState是新增的按调用者解析的字段 (§3.4:书库说的是「你的书架有没有」)。只在"off"的行上画徽章: 给一整个目录的书都画「在书架上」是用一列噪音盖住唯一一行例外, 而例外正是读者来这里找的东西。
批量条两个方向无条件都给,不做状态判断:一次选中可能一半在架一半不在, 读者的意图是「让这些变成一样」,而这是有方向的。把其中一个方向藏起来, 一半在架的选中就只能往一个方向推——不能去的那个方向恰好是这次报告的方向。
3.6 悬浮提示,不占位
状态行(同步中…、离线 · 进度待同步)是浮在正文上方的药丸,不是
插在顶栏和正文之间的横条。
这不是审美。横条在 flex 列里占一行,于是每一次状态变化都会把正文往下推一行
再收回来——读者正在读的那句话会跳。一条他没要过的提示挪动他正在读的字,是
这个界面里最不该发生的事。ui:review 里有一条断言专门盯着这个:把状态行
从空切到有字再切回来,正文顶部的 y 必须三次相同。
同理,提示不吃指针事件(pointer-events: none):一条挡住读者本来要点的
那一下的提示,代价是读者少翻一页。
3.7 半屏面板
目录和阅读设置是从底部升起的半屏面板,带遮罩和把手,上半屏仍然看得见 正文。
之前的做法是 inset: 0 的全屏页,把书完全盖住。全屏对一个目的地是对的,
对一个控件是错的:选章节、调字号都是对下面那一页的调整,全屏让读者为了做
调整而离开那一页,回来时排版已经变了、他也没有了对照。
三条配套,都不是装饰:
- 遮罩:没有遮罩的面板读起来像是页面的一部分。它同时也是「点一下关掉」 的命中区,也就是除 ✕ 之外的第二个出口。
- 把手:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是 可供性,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以 它只是视觉线索,真正移动的是面板自己的滚动。
- 高度:
min(62%, 34rem),且不超过 78%。一千二百章的目录想要尽可能多的 空间,十一行的设置不想要比它需要更多的空间。
面板打开时它拥有点击:旁边露出的那一条正文不翻页(§7 的优先级)。
3.8 事件层级
一次触摸只属于一个目标,最里面那个赢。ui/gestures.ts 是这条规则的唯一实现,
顺序是:
- 读者按到的是控件(链接、按钮、输入、滑块)→ 控件拿走这次手势,不翻页;
- 面板开着 → 面板拿走(点遮罩=关闭,不是翻页);
- 否则拖动是滑动翻页;
- 否则短按落在三段点击区里。
有两个坑是文档级别的,改这块之前必须先知道:
- 必须用
composedPath(),不能用event.target。 书的正文在 shadow root 里,而 shadow 边界会重定向事件:点正文里的<p>,监听器看到的 target 是 shadow host。用target写不出「段落」和「同一个 shadow 树里的按钮」的区别。 - 阅读面自己是个滚动容器。滚动模式就是让 shadow host 滚起来。所以 「可滚动的祖先拿走手势」这条规则必须排除它——否则它吃掉每一个手势,每一次 点击都归属某个滚动容器,翻页层级正确但永远到不了。这一条是 2026-09 那轮 「点击页面没反应」的真正原因。
- 三段点击区是布局几何,不是视觉几何。
getBoundingClientRect()和event.clientX都在视觉视口坐标里,两者相减得到的是视觉距离;而点击区是 版心宽度的三等分,版心宽度是布局像素。页面被捏合放大(或任何祖先变换)时 两者不相等,误差可以超过判定阈值的一半——读者点中间三分之一想收起工具栏, 结果翻了页。所以宽度取offsetWidth,偏移按两个盒子的比例rect.width / offsetWidth换算回布局空间;无变换、无缩放时这个比例正好是 1,那一行是恒等 变换而不是一次单位转换。
3.9 纯文本(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.9.1 章节内容的最小约束(图片与非 HTML 正文)
读者报的是「图片缺少样式,移动端显示溢出了」——一张 1400px 宽的插图在一栏 342px 的版心里画出来 1400px,两头都出了屏幕。它属于这一类:没有任何断言覆盖 过它,因为 UI 评审的插图 fixture 只有 240px 宽,塞得进版心,于是评审一边说 「插图能画出来」一边让真实的缺陷完全复现。
所以有一条独立于书的样式表(web/src/ui/shadow.ts 的
CHAPTER_CONTENT_STYLESHEET),它只声明作者自己也会声明的那些属性,并且
位置在书自己的样式表之前——同优先级下后写的赢,所以作者写
img { width: 60% } 仍然有效。三条规则,各有各的理由:
- 图片收进版心:
max-inline-size/max-width取min(100%, …),height: auto。只夹宽度会让图压扁,这是修第一个数字时最容易引入的缺陷, 而它在截图里看不出来(对角线的角度变了但你没得比);auto才保住原比例。 另外给max-block-size,否则一张竖图会把图注挤到下一页,两者一起变得不可读。 - 居中显示:
display: block+margin-inline: auto。行内图片坐在文字基线上, 底下会多出降部那一截行距;block也是「比版心窄的图居中」这条能成立的前提。 - 没有尺寸的图片留出比例:
aspect-ratio让章节目录在图片到达之前就有正确的 高度,否则读者正在读的那一段会被一张还没加载完的图往下顶,位置就丢了。
§3.9 的 TXT 排版走的是另一条路(.txt-body,只挂在纯文本上);这一节管的是
所有格式的章节内容,包括 EPUB 里作者自己写的标记。
3.10 沉浸式阅读
顶栏和底栏是一个状态,不是一个状态乘以二。 读者要收起导航就是要看到正文,
上面收了下面没收,就是读者报的「只收起了一半」——底部那条一直压着每一页的最后
两行。所以两条栏由 .reader-screen 上的一个 data-chrome 属性一起开关,写法上
不可能只藏一半,动效、时长、缓动也完全一致(--motion-duration + 同一缓动)。
三条实现约束,改这块之前先知道:
- 两条栏是浮层,不是占位。
position: absolute贴在屏幕上下缘,因此页面 在两种状态下都是整屏,正文一个字都不会动。这是读者报的「工具栏显示隐藏时, 内容会上下滑动」:当时两条栏在正常流里,stage 是flex: 1 1 auto,于是每次 开关工具栏页面就重新排版、正在读的那一行上下滑 53px。工具栏是浮层,和正文不在 同一个流里——读者的判断是对的。 - 页面铺满整屏,不为浮层预留任何高度。
.stage-host是inset: 0, 没有padding-block。这是读者报的「内容要铺满,不要预留 stage」:版心 曾经按两条栏的高度留固定边距,于是页面在两种状态下都永久比手机矮 9rem ——读者收起了工具栏、要的就是整屏,顶上顶下却还是两块空白纸。 留边距的理由(浮层会盖住正文)本身没错,所以浮层的解法是浮层自己的:两条栏 用半透明底加backdrop-filter(--reader-surface-float),读者能透过 工具栏看到它盖住的那一行。.book-flow自己的padding-block是页面 边距,上下对称、与工具栏无关,一直在。 - 两条栏的高度各自只声明一次(
.reader-screen上的--reader-chrome-top/--reader-chrome-bottom):栏本身、以及底栏上方那条朗读条(.tts-bar的bottom)读同一个数,不会各说各话。 visibility在动画结束后才生效(transition: visibility 0s linear var(--motion-duration)),过渡期间由pointer-events: none兜住:滑出的过程 中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的 返回按钮。- 右侧的快捷按钮列和两条栏一起出现、一起收起(
.reader-rail),由同一个data-chrome属性开关,和顶栏底栏共用一套动效。按钮住在正文的版心旁边 (宽屏时用--reader-measure算出的天头地脚里),不压字。它不属于沉浸态: 这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在 正文右边缘的按钮,就不是读者要的「只剩正文」。 - 快捷列只留页面内就能完成的调整:夜间/日间(主题)、字号、听书。
这条是读者报的「竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮」定下来的:
被去掉的五个都是目的地而不是调整——目录、设置各自打开半屏面板,字体/行距/
页边距是值的一档而其完整范围住在设置面板里——所以这一列同时也装着它们的时候,
它就是一列浮在正文上的、更差的设置面板。留下的是读者不想打开任何东西就会去动的
那几个(尤其听书:他伸手的时候正要离开屏幕)。
每个按钮仍然是该项设置的一档,值和设置面板里的行取自同一张阶梯
(
FONT_SCALE_LADDER、THEME_LADDER),所以两处不会各自漂移出一个对方到不了的值。 列本身仍要保证整组控件在页内:max-height由两条栏的高度算出来, 装不下时overflow-y: auto,并且滚动条是画出来的(3px 细线而不是scrollbar-width: none)——一个只能靠滚动找到的控件必须看起来能滚,否则 对读者就等于不存在。overscroll-behavior: contain也在,免得滑到头那一下把 手势交给下面的正文。 - 收起时正文一角仍留两个读数(
.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)。
手势上,三个区各管一件事,互不重叠:左右三分之一只翻页,中间三分之一 只切换工具栏显隐。
这一条改过两轮,第二轮的起因是读者报的「在点击左右两边时只需要翻页、不需要显示 工具栏,只有在中间点击时才需要切换工具栏显隐」。第一轮的行为是:收起状态下左右 三分之一翻页并且把工具栏叫回来(理由是没有别的出路)。理由成立,结果和手势 打架——读者只是想往后翻,得到的却是新的一页外加一条压在刚读到的那行上的工具栏, 于是沉浸态根本待不住,每翻一页都被撤销一次;而真正想要控件的读者有一个专门的、 一眼可见的表达方式:点中间。 所以现在两个手势是互斥的,各有一个含义,这正是原来缺的性质: 左/右 = 翻页,中 = 显隐工具栏。翻页不会带出任何控件,要控件的点击也不会翻页。
3.11 朗读条
朗读条(.tts-bar)和两条栏一样是浮层,贴在底栏正上方(bottom: var(--reader-chrome-bottom)),z-index 用同一个 --reader-layer-bar;但它
不跟工具栏一起收起——读者是边听边走的人,收起工具栏是为了看正文,不是为了
控制不了播放。
这条是读者报的「TTS 阅读体验需要优化,没法停止」逼出来的两条实现约束:
- 它必须画在正文之上,才算存在。 它原本是
.reader-screen这个 flex 列的 普通子元素,而第一个子元素.stage-host是inset: 0的绝对定位、正文又画在--reader-layer-page。静态兄弟排在浮层下面:条画出来了、按钮能聚焦、aria-label也对,没有任何一个能被点到。所以它显式声明position: absolute加 z-index——一个实心按钮点不到,读者只有一个说法:「没法停止」。 - 控件自己一行,正文和滑杆另起一行。 原本七个东西挤在一行里,句子拿
flex: 1,于是它成了唯一会被压缩的东西,而最后一个(停止)在 360px 上被推出 右边缘裁掉。现在上行是flex: 1 1 0的五个等宽档(上一句 / 播放暂停 / 下一句 / 句数 chip / 停止),下行是句子加滑杆:控件的宽度是等分出来的,屏幕多宽都放得下。 - 停止是方块不是 ✕。 ✕ 读起来是「关掉这条」,而那正是暂停不做的事——想要
声音停下的读者按了它,条消失了,书还在念。停止单独用主色底(
.tts-stop)标出, 它是一行五个控件里唯一一个破坏性的。
播放器元素本身(<audio>)永远不进文档,并且显式带 display: none 与
aria-hidden:媒体元素没有读者想要的控件(条上都有),而一个会渲染它的 WebView
只会在书上画一个黑色播放器方块。这是读者报的「audio 需要隐藏」。
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 章 |
| 快捷按钮列只在沉浸态出现 | ✅ 收起工具栏后按钮还在 | 收起后正文右边仍压着一列浮层 |
| 朗读条是 flex 里的一行 | ✅ 按钮渲染出来了、aria-label 也在 |
它是静态兄弟、被 inset: 0 的 stage 盖住,实心按钮一个都点不到 |
| 朗读条的控件挤在一行里 | ✅ 每个控件单独看都在 DOM 里 | 手机宽度下最后一个(停止)被挤出屏幕外,那就是「没法停止」 |
| 快捷列装的是打开面板的入口 | ✅ 每个按钮都能点开东西 | 浮在正文上的第二份设置面板,比面板本身更难用 |
| 版心按工具栏高度留固定边距 | ✅ 开关工具栏时正文一行不动 | 两种状态下页面都矮 9rem,收起了工具栏也还是两块空白纸 |
| 分栏的 column-gap 是 0、页边距是内边距 | ✅ 滑杆能拖、页码会变 | 每页右边缘露出下一页的头几个字,翻页「样式不对」 |
所以 npm run ui:review 做两件事,两件都要:
- 截图(
docs/ui-review/*.png)。这一步没有断言可以替代。「只是丑」不是 测试失败,但它是缺陷。截图交给评审的人看。 - 量尺寸(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人
不在流水线里。当前量的是:顶栏/底栏/快捷列三条 band 一起收放、收起时正文占满
且只留两个读数、面板不遮住整页且留出 ≥120px 正文、面板有遮罩和把手、滑杆量
的是章节内页数并且拖到第 N 页就停在第 N 页、状态提示不推动正文、每个控件
≥28px 且有名字、没有横向溢出、翻页不再成对发 sync。
还有几条是读者报告补进来的,每一条都先是截图再是断言:
出版方写了 1400px 的插图收进栏内且比例不变(只夹宽度会把它压扁,
所以比例必须单独量);快捷列在 390×844 和 390×420 两种屏高下都在页内,
装不下的控件滚得到、滚动条画了出来(
scrollbarWidth > 0,而不是读回overflow-y);快捷列里确实有「听书」。 书库拆成两个屏幕之后又有六条(§3.4.1): 浏览页有搜索框而文件管理页没有;浏览页的网格铺满窗口而不是被书架的 宽度上限截住(量网格宽度和窗口宽度,再加封面占了几行);字段两边的留白 左右相等且封面比它再内缩(padding: 0时量出来是 0,这一条正好抓到了一个 token 挂在别的元素上、留白静默没生效);输入搜索词之后 URL 和网格一起变, 而且是真敲进去的,不是直接设value——字段有防抖,防抖才是「查询变成一次 导航」的那一步,直接设值会在一张 URL 从未改变过的页面上通过;文件管理页画的 是行、有上传/新建、没有搜索框;以及从文件管理按「浏览书籍」是换页, URL 跟着换。
它跑在真实的生产产物上:npm run build 之后由 tools/ui-review/server.mjs
起一个替身 API,再让真实 Chromium 以 390×844、2x 逐屏加载。不是 storybook——
组件故事会喂进手写的 state,而上面那些缺陷全都发生在「屏幕对真实响应」的时候。
断言挂在 client-ui-review 流水线上,只在界面相关文件变化时触发。
8. 改动这份界面时的检查单
- 新颜色 / 新间距 / 新时长 → 先看
:root里有没有,没有就加令牌, 不要在规则里写字面量。 - 新图标 → 加一条路径到
tools/icons/paths.mjs,键名用这个项目自己的英语词 (§1.2:不许借别的图标集的名字,test/icons.test.ts会拒),跑npm run icons, 把名字加进那张词汇表,然后用IconButton引用名字。 - 新控件 → 确认它出现在 §2.4 的焦点选择器列表里。
- 新动画 → 用
--motion-duration和--motion-ease,不要自己写时长。 - 有状态变化的控件 → 对照 §5 那张表,确认用的是对的那一种。
- 图标按钮 →
label必填,且这条 label 是这个按钮自己的作用, 不是它所在的区块的名字。 - 图标 + 文字并排 → 用
IconTextButton,不要自己拼一个<button>加一个<span class="icon">;图标加文字正是最容易歪的组合,而它每次歪的方式都 一样(字形落到文字基线上)。 - 动图标相关的东西 → 先读 §1.4。图标对齐是字体度量和 CSS 两半一起决定的, 只改一半会得到一个更小的偏移,而不是没有偏移。
- 动布局 → 跑
npm run ui:review,并且看截图。 断言只覆盖已经量过的那些 规则;新缺陷的第一道防线是眼睛。- 给绝对定位的字形居中时,量墨迹(
Range.getBoundingClientRect()), 不要量盒子——盒子居中而墨迹不居中是 §1.4 那个缺陷的第三次出现,每次 都能活过一轮「盒子看起来在中间」的检查。
- 给绝对定位的字形居中时,量墨迹(
- 加一条能一直看着的断言,如果这条规则是能量出来的。下一个人不会看到 这次截图。
- 加一个列表页 → 页数由服务端给,页由服务端切,页是 URL 的一部分(§3.5)。 在客户端切好一页再丢掉、或把页码只放在组件状态里,都会在 Back、Forward 和 刷新这三件事上分别坏一次,而这三件事没有一条是功能测试会先撞上的。
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/shelf-screen.tsx |
书架;排序(§3.4.2)、分页器(§3.5)与书库入口(§3.4) |
web/src/ui/library-screen.tsx |
书库的两个屏幕:LibraryBrowseScreen(商城式浏览页,§3.4.1.1)与 LibraryFilesScreen(文件管理页,§3.4.4);面包屑、分页器、上传后重读(§3.5) |
web/src/ui/shelf-order.ts |
排序的唯一定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排 |
web/src/ui/dialog.tsx |
提问(文本框/确认/选择/改资料),两个书库页面共用 |
web/src/ui/pager.tsx |
翻页器,书架与书库共用(§3.5) |
web/test/shelf-screen.test.ts |
书架排序(默认项、recent 是客户端排)、分页的算术、超界怎么夹 |
web/test/shelf-order.test.ts |
排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
web/test/library-screen.test.ts |
浏览页:封面网格、文件夹作用域的搜索、「加入书架」发的是哪条路径 |
web/test/library-access.test.ts |
文件管理页的管理员判定(§3.4.4):路由词汇,以及非管理员拿到的是浏览页 |
web/test/manager.test.ts |
书库文件页的分页、上传后的重读,以及「破坏性操作必须先选中再确认」 |
web/src/ui/gestures.ts |
手势与事件层级(§3.6) |
web/src/ui/reader-chrome.tsx |
阅读页的顶栏/页脚/面板/朗读条/沉浸式按钮列 |
web/src/ui/shadow.ts |
书内容 shadow root;含纯文本排版表(§3.9) |
web/src/formats/segments.ts |
纯文本分段(「一段是什么」的唯一定义,§3.9) |
web/src/ui/reader-view.ts |
章节渲染与位置;retypePlainText 是客户端排版落地处(§3.9) |
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 |