Files
reader/docs/ui.md
T
hectorqin 1c2d07ba4c fix(web,server): 让服务端只交章节,并去掉成对的 sync 请求
服务端仍在做排版:`chapter-html:<n>` 自己断段、自己提升标题、自己吐
`<p>`。于是 reader 设置里的缩进和段间距只能作用于服务端已经渲染好的那一份,
流式那条路(`chapter:<n>`)拿不到段落,同一本书因为走哪条传输而看起来不一样。
客户端的 `retypePlainText` 于是把服务端断好的段落再拆一遍——同一个判断被两个
地方执行,两次的结果就会分歧。

`POST /api/v1/sync` 的响应本来就是合并后的状态(服务端在两个接口上返回同一份
`pull`),但客户端把它丢掉,然后又为同一份数据发一个 `GET`。读者每翻一页都发
一个 POST 加一个 GET,网络面板里成对出现。另外 `setState` 每次调用都广播,而
阅读页的状态监听会重绘整棵 chrome 树,一个同步循环通知三次。

现在服务端只把整章字符交出来(`text/plain`,`chapter-html:` 不截断),断段、
去段前空格、提升标题全部由 `web/src/formats/segments.ts` 一处决定;push 的返回
值直接喂给 `applyPulled`,空闲轮询才真的发 GET;`setState` 值未变就不广播。

「这一章是不是纯文本」改由 manifest 声明(item 的 `format: 'html'` →
`Section.plainText` → `isPlainText()`),不再靠 `.txt-body` 嗅探:服务端现在发
裸字符,嗅探对一章节全是纯文本的章节会答「不是」,读者会看到一本没有段落、
没有缩进、样式表也没挂上的小说。`.txt-body` 标记降级为旧服务端/旧缓存的兜底。

放弃的方案:保留服务端渲染、只在客户端覆盖样式。缩进和段间距是阅读偏好,服务端
渲染就等于把每次调整变成一次往返,而这个往返正是问题本身。也考虑过让
`chapter-html:` 直接返回服务端渲染的裸 `<p>`(不带 wrapper),但那仍然有两个
地方在判断「一段是什么」。

Ref: #34
2026-09-18 23:39:55 +08:00

28 KiB
Raw Blame History

界面规范

这份文档规定界面长什么样、为什么。它约束的是客户端的外观层,不涉及排版 (书的排版见 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。

样式表那一半:

.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-html:<n> 是给读者整章读的,不截断,内容类型是 text/plain。它不再断段、不再提升标题、不再输出 <p>:断段是阅读 决定,服务端做就等于让 windowed 那一份有段落、流式那一份没有,也让读者的 缩进设置变成一次往返。
  • 客户端排版(web/src/formats/segments.ts):splitTextParagraphs 是 「一段是什么」的唯一定义,本地加载的 TXT、windowed 的 chapter-html:<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-html:<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)。没有它,收起工具栏就是一个 「必须离开才能操作」的状态:改个字号要「点回来 → 调 → 再点走」三次交互, 而正文在每一次下面重排。有了它,读者最常用的四个动作永远一步可达,而且按钮 住在正文的版心旁边(宽屏时用 --reader-measure 算出的天头地脚里),不压字。
  • 收起时正文一角仍留两个读数(.reading-indicator):当前章节名在左上、 页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏 就是要看正文,但仍然要知道自己在哪一页,让他为此再点一下正是收起状态要 省掉的那次交互。两个读数 pointer-events: none(它们是读数不是控件,点它们 必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。

顶栏与底栏各自的形态(对应参照稿):

  • 顶栏是一排「图标在上、文字在下」的等宽按钮(.top-button),不是「标题 + 返回箭头」。参照稿里它是读者扫着用的一行工具栏,每个控件一样重、一样一步 可达。书的名字曾经写在这里,现在它属于 .reading-indicator——读者找标题时 眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕真能做到的动作 (返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地, 一个点了没处的按钮比少一个按钮更糟。
  • 底栏是两行(.progress-row + .chapter-nav):一行可拖动的进度滑杆加页码 读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的进度条—— 条只能报告位置,读者想到一章末尾得连点四十次「下一页」;input[type=range] 一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、aria-valuenow)都是 原生的,不用自己实现一遍。

手势上,中间三分之一在两个方向上都是开关;左右三分之一只在 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 里的一行 ✅ 文案正确渲染 每次同步都把他正在读的那句推走
图标按钮自己拼的 ✅ 点击有反应 图标和文字差半个字高

所以 npm run ui:review 做两件事,两件都要:

  1. 截图(docs/ui-review/*.png)。这一步没有断言可以替代。「只是丑」不是 测试失败,但它是缺陷。截图交给评审的人看。
  2. 量尺寸(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人 不在流水线里。当前量的是:面板不遮住整页且留出 ≥120px 正文、面板有遮罩和 把手、状态提示不推动正文、每个控件 ≥28px 且有名字、没有横向溢出。

它跑在真实的生产产物上: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