Files
reader/docs/ui.md
T
Claude c0dc54efab feat(reader): one theme for reading and media, V3 reader chrome
The book now wears the same six app themes as every other page (plus
follow-system); the separate white/sepia/green/dark reading themes are gone.
The 外观 sheet's swatches are the app themes drawn in their own colours and
the day/night rail button switches between 暖纸 and 深夜. The bottom bar
becomes a rounded sheet. 界面 is renamed 外观 in the bar, panel, review
scripts and spec.

Shelf title is 书架 in the serif face and loses the now-redundant headphones
link. docs/ui.md gains the V3 shell section.
2026-10-19 22:33:27 +08:00

46 KiB
Raw Blame History

界面规范

这份文档规定界面长什么样、为什么。它约束的是客户端的外观层,不涉及排版 (书的排版见 architecture.md §8 —— 那里的原则相反:对书不动手)。

之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复 改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem, 这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个 可以对照的基准。


1. 图标

图标统一使用 lucide-react 的 SVG,web/src/ui/icon.tsx 集中维护名称映射,线宽 1.8,尺寸 1em。 界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。 装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。

2. 设计令牌

全部定义在 web/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.4.1 末段、§3.5.2)。
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), 否则一屏上有两个清除控件,其中一个是浏览器自己的蓝色小叉。

「加入书架」画在 shelfState: 'off' 的书上,而 shelfState 来自这一页自己的书单**。**

这一条修了两遍,第一遍修错了地方,值得记下来——因为错的那个方案在当时看起来更省事:

  • 第一版从文件列表上取状态,并且要求「这本书的文件得在本页上」。理由是端点收 的是路径,而 BookDto 没有路径,两者只能靠去掉扩展名的文件名去对。 于是文件夹第二页上、文件在第一页的那本书报不出路径,卡片就不画控件。
  • 第二版(#40 退回第一版之后)把整个 join 拿掉了:写入带的是 book.id (§3.5.2),所以「文件在哪一页」不再影响任何事,状态也从书单上取。于是三态变成两态:
情况 画什么
shelfState: 'on' 不画(已经在了)
shelfState: 'off' 画「加入书架」——这一页存在的理由
shelfState 缺省(老服务端没有这个字段) 不画(按钮会写出一个没人能预测的结果)

这一页问的是索引,不是读者自己的书架(/api/v1/books?scope=library&path=…): 问书架的话,返回的每一本都已经在书架上,shelfState 恒为 'on'—— 这一页存在的那个动作永远画不出来。这是第一版真正的病根,比 join 更早: 一个商城只陈列你已经买过的东西,就没有东西可以加。

网格上面那行(「这一页有 N 本还不在书架上」)数的是同一份集合, 而不是整个文件夹:一行说 12 本、下面只有一个角标,是一句和自己画的东西矛盾的话。 ui:review 里有一条断言同时盯两边:角标数 > 0 且 < 卡片数—— 恒为 0 和恒为全部,都是这一页坏掉的样子。

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:不存在则插入,存在则清标志。
  • 书库不显示书架状态。 BrowseEntry.shelfState 是按调用者解析的字段 (§3.4:书库说的是「你的书架有没有」)。只在 "off" 的行上画徽章: 给一整个目录的书都画「在书架上」是用一列噪音盖住唯一一行例外, 而例外正是读者来这里找的东西。

这个徽章留下了,那个按钮没有(#40 的下一轮:「书库管理页面去掉 从书架拿掉 按钮」)。 区别不是妥协,是这两件事不同的归属:

  • 徽章是「答案」,而这个屏幕的职责就是回答「我的书去哪了」。一条不说自己不在 书架上的行,就是这个屏幕什么都没说。
  • 按钮是「写入」,而它写错了地方。 文件页上其他每一个控件(上传、重命名、移动、 删除、改资料)改的都是挂载盘上的东西;下架 改的是一行 user_books, 一个文件都不动。它因此是这个菜单里唯一一个后果和这一页的承诺对不上的条目, 而它挨着的就是「删除」。它还答错了人:这一半是管理员的(§3.4.4 路由按角色守), 而书架是每个读者自己的。

所以文件页现在只写盘。两个方向都搬到了读者站的那一屏上(§3.5.2 / §3.4.1)。

3.5.2 两个方向都要在读者站的那一屏上,而且按书 id 写

书架是每个读者每天打开四十次的那一屏,所以方向落在封面自己上 (#40:「补全完善书架的加入、移除逻辑」):

  • 每张封面右上角一个「⋯」,点开是这一本书的动作。它到现在只有一个条目 (「从书架拿掉」),仍然是菜单:入口才是「这个方向存在」的证据,而在每张封面上 直接画一个「从书架拿掉」按钮,是在读者想打开书的那根手指下面放一个破坏性控件。
  • 它是图标按钮,不是滑动、也不是长按。 书架是纵向滚动的,横向手势跟它所在的 滚动抢事件;长按没有任何可供性——同一条理由文件行已经在用(§3.4.3)。
  • 先问,再写。 remove 是两个方向里唯一拿走东西的那个,也是读者最容易误读 后果的那个。确认框里写的是读者真正在问的那句话:磁盘上的文件一个都不会动。
  • 写入之后重读这一页,而不是本地删掉那张卡片。 列表是这次动作的答案, 而只有服务端知道书架现在有什么;本地删卡会让计数、页码和离线镜像三方各说一套。 消息 从书架拿掉 N 本 在刷新之后通过全局浮动提示显示,目录摘要继续展示书籍数量。

写入带的是 book.id,不是路径。 这是 #40 报的「从书架移除时,找不到「xxx」 在磁盘上的路径」的根因,而它值得单独说,因为错的那个方案看上去更直接:

卡片手里只有一本 Book(一个 id、以及扫描器记下来的东西),而端点当时收的是 路径。两者之间隔着一个 join,客户端是这么补的:列出书库根目录的第一页, 拿书名去和文件名对(去掉扩展名)。这个猜测有两处必然失手:

  • 书名不等于文件名,而这是常态而不是例外——扫描器会从文件里读出书名。 报告里的那本书叫「半小时漫画宇宙大爆炸(半小时读完138亿年宇宙史,一口气搞懂大爆炸、 奇点、黑洞、引力波、暗物质……混子哥陈磊新作!)」,磁盘上那个文件不叫这个, 于是什么都没有匹配上。读者被告知这本书找不到——在他要求把它从自己的书架上 拿掉之后,而那是文件叫什么名字完全无关的一件事。
  • 列表是分页的,所以根目录第一页之外的书、以及任何子目录里的书, 在那次查找里根本不存在。

映射其实已经在服务端了(book_files.book_id → rel_path),客户端一直在猜一件 服务端已经知道的事。现在写入带 id,服务端按 id 解析路径:书名和文件的位置都不再参与。 端点两种命名只收一种(paths 与 bookIds 同时给会被拒)——两份命名说的是同一个写, 同时带上就是「客户端自己不知道该说哪一个」,合并还会让同一本书被写两次、被数两次。

浏览页(§3.4.1)用的是同一对方向,也带 id,所以同一本书从书架还是从书库下架, 发出去的是同一句话。文件页两样都不用了(§3.5.1)。

3.5.3 卡片是容器,按下去的地方是里面的「打开」按钮

一张卡片一旦带上第二个动作,它就不能再是一个 <button>:<button> 里套 <button> 是非法结构,浏览器对它的处理是把内层搬到外面,那是一种没人设计过的布局。 所以卡片是容器,.book-open 是打开目标,.book-more 是它的兄弟。

配套的两条,都在 ui:review 里有断言:

  • .book-more 的命中区不小于 28px(§3.3)。画在封面右上角的字形,正是手指 最容易按空的那种控件。
  • 打开区仍然覆盖整张卡。放大字形不能吃掉读者用来开书的那块面积,所以两条 一起量:字形够大,而且打开区没被挤小。
  • 静止不透明度 0.55,且始终可聚焦。 只在 hover 时出现的控件在手机上等于不存在, display: none 的控件连键盘和读屏都够不着。@media (hover: hover) 里才允许它 完全隐形——有指针的宿主才有"先划过再点"这回事。

两屏的动作文案来自同一个地方(shelf-membership.ts),因为它们是同一个问题从两端问: 加入书架 N 本 / 从书架拿掉 N 本,而不是批量口径的「已更新 N 本」——读者按的是 加入书架,他想知道的只有那本书动没动。applied 为 0 而 failed 非空时,句子说的是 「N 项不是书库里的书」:一个看起来能按的按钮配上「0 本」就是「这个按钮坏了」的来源。

3.6 悬浮提示,不占位

登录、书架、书库、文件管理、阅读、书源和扩展页面的操作反馈统一使用 Notyf。 web/src/ui/notifications.ts 提供全局 notify / dismissNotice 接口;状态驱动的组件 使用无 DOM 输出的 FloatingNotice 适配器,页面不再各自创建提示条或消息计时器。

全局容器固定在视口底部并避开安全区,宽度随内容变化,最大 28rem,手机两侧至少留 1rem。 最多同时显示三条;加载提示持续显示,进度原位更新;普通提示默认 6–12 秒后消失,也可手动关闭。 成功、错误和加载共享主题样式,错误使用 alert,其它消息使用 status。 服务端返回的文案均按纯文本显示,不执行 HTML。

容器和消息正文不拦截点击,关闭按钮可以操作,避免提示挡住阅读按钮。原生弹窗打开时,同一个容器移入弹窗的顶层, 关闭后回到 body;消息不抢焦点。提示出现、更新和关闭不得改变内容区尺寸或滚动位置, 这些行为在手机和桌面 Chromium 中验证。数量摘要、搜索统计和操作说明继续作为页面内容展示。

3.7 半屏面板

目录和阅读设置是从底部升起的半屏面板,带遮罩和把手,上半屏仍然看得见 正文。

之前的做法是 inset: 0 的全屏页,把书完全盖住。全屏对一个目的地是对的, 对一个控件是错的:选章节、调字号都是对下面那一页的调整,全屏让读者为了做 调整而离开那一页,回来时排版已经变了、他也没有了对照。

三条配套,都不是装饰:

  • 遮罩:没有遮罩的面板读起来像是页面的一部分。它同时也是「点一下关掉」 的命中区,也就是除 ✕ 之外的第二个出口。
  • 把手:从底部升起的面板被理解为可拖拽的。拖动本身没有实现——把手是 可供性,遮罩和 ✕ 是两个真实的出口。画一个做不到的把手比不画更糟,所以 它只是视觉线索,真正移动的是面板自己的滚动。
  • 高度:min(62%, 34rem),且不超过 78%。一千二百章的目录想要尽可能多的 空间,十一行的设置不想要比它需要更多的空间。

面板打开时它拥有点击:旁边露出的那一条正文不翻页(§7 的优先级)。

3.8 事件层级

一次触摸只属于一个目标,最里面那个赢。ui/gestures.ts 是这条规则的唯一实现, 顺序是:

  1. 读者按到的是控件(链接、按钮、输入、滑块)→ 控件拿走这次手势,不翻页;
  2. 面板开着 → 面板拿走(点遮罩=关闭,不是翻页);
  3. 否则拖动是滑动翻页;
  4. 否则短按落在三段点击区里。

有两个坑是文档级别的,改这块之前必须先知道:

  • 必须用 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 手机阅读

  • 顶部:返回、书名、当前章节。
  • 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、外观、设置。
  • 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
  • 顶部左/右、底部左/右阅读信息可在“外观”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
  • 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
  • 四个面板使用统一底部半屏布局;外观放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
  • “外观”面板按使用频率排序:字号、行距、主题(V3 六套应用主题加“跟随系统”,用各主题真实的底色与墨色绘制的色块)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、亮度和“页眉页脚”。
  • TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
  • 阅读与影音共用同一套六套应用主题(暖纸、雾蓝、蔷薇、原野、深夜、石墨)和“跟随系统”,没有单独的阅读主题;日夜快捷按钮在暖纸与深夜之间切换。漫画隐藏文字排版选项。

3.11 朗读

朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。 “从当前位置朗读”使用当前可见正文或选区作为起点,是面板里的第一项和唯一主按钮。其后依次是语速(滑杆加 0.8/1.0/1.2/1.5/2.0 常用档)、语音、章节播完;音调与音量收进默认折叠的“更多”。不可用的语音设置根据引擎能力隐藏。

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 评审验证真实页面在目标视口中的布局、可见性和溢出。两者使用同一份生产 Web bundle,但关注点不同:功能测试不能代替截图检查,截图也不能代替可自动断言的尺寸检查。

当前入口是 npm run ui:review。它运行 web/tools/ui-review/react-app.mjs,用真实 Chromium 加载构建后的 React 应用,覆盖书架、书库、书源、设置和影音页面的移动端与桌面端场景。默认产物写入被 Git 忽略的 artifacts/ui-review/react-app/,包含场景截图和 verification.json。

verification.json 是 CI 读取的机器结果。提交或排查 UI 改动时,先检查 passed、页面错误、横向溢出和场景列表,再查看对应截图。截图数量随场景变化,不在文档中固定写死。

阅读器和书源的专项检查仍可单独运行:

  • npm run ui:review:reader --prefix web:阅读器舞台、面板、翻页和窄屏场景,输出到 artifacts/ui-review/reader/;
  • npm run ui:sources --prefix web:书源页面与扩展流程,输出到 artifacts/ui-review/sources/。

这些脚本只生成临时评审产物,不要把截图或报告提交到 docs/。CI 的 client-ui-review 只在界面代码、界面规范或评审工具变化时运行。


8. 改动这份界面时的检查单

  1. 新颜色 / 新间距 / 新时长 → 先看 :root 里有没有,没有就加令牌, 不要在规则里写字面量。
  2. 新图标 → 加一条路径到 tools/icons/paths.mjs,键名用这个项目自己的英语词 (§1.2:不许借别的图标集的名字,test/icons.test.ts 会拒),跑 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.4。图标对齐是字体度量和 CSS 两半一起决定的, 只改一半会得到一个更小的偏移,而不是没有偏移。
  9. 动布局 → 跑 npm run ui:review,并且看截图。 断言只覆盖已经量过的那些 规则;新缺陷的第一道防线是眼睛。
    • 给绝对定位的字形居中时,量墨迹(Range.getBoundingClientRect()), 不要量盒子——盒子居中而墨迹不居中是 §1.4 那个缺陷的第三次出现,每次 都能活过一轮「盒子看起来在中间」的检查。
  10. 加一条能一直看着的断言,如果这条规则是能量出来的。下一个人不会看到 这次截图。
  11. 加一个列表页 → 页数由服务端给,页由服务端切,页是 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/features/shelf/pages/ShelfPage.tsx 书架;排序、分页器与书库入口
web/src/features/library/pages/LibraryPage.tsx 书库浏览、书籍列表和文件管理路由;搜索、面包屑、分页器、上传后重读
web/src/ui/shelf-membership.ts 两个方向的措辞与批量报告那句话的唯一来源(§3.5.2)
web/src/ui/shelf-order.ts 排序的唯一定义:方向、客户端/服务端的键怎么翻译、阅读时间怎么排
web/src/ui/dialog.tsx 提问(文本框/确认/选择/改资料),两个书库页面共用
web/src/ui/pager.tsx 翻页器,书架与书库共用(§3.5)
web/test/shelf-order.test.ts 书架排序(默认项、recent 是客户端排)、排序方向和稳定性
web/test/shelf-order.test.ts 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定
web/test/react-router-routes.test.tsx React Router 的页面、影音嵌套路由和权限边界
web/test/media-library-list.test.tsx 媒体库列表、分页和空状态
web/test/media-library-editor.test.tsx 媒体库编辑、权限和保存后的重读
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)
artifacts/ui-review/react-app/ 当前 React 评审截图与 verification.json(生成,不提交)
web/src/styles/reader.css 令牌、组件样式、交互状态、prefers-reduced-motion

9. V3 外壳与导航

  • 一个导航:手机底部四个标签(首页、书架、影音、我的),900px 以上改为左侧栏,影音下列出影视、音乐、有声书三个频道。取代旧的媒体频道栏。
  • 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 web/src/app/AppNav.tsx 的 navMode。
  • 主题在应用壳层统一应用到除登录外的所有页面,包括书本。书架、书库、书源、首页、阅读器的 --reader-* 令牌在 web/src/app/shell.css 中映射到当前主题。
  • 频道色(阅读、影视、音乐、有声书)是主题令牌 --media-theme-ch-*,每个主题都由 media-theme-contrast.test.ts 守住对比度。
  • 首页显示在读的书、未看完的影视和有声书、书架一行;我的页只做入口,不拥有任何设置。