Files
reader/docs/ui.md
T
hectorqinandClaude Opus 5.5 52b757bbd3 fix(web): clean web-page markup out of TXT chapters before typesetting
Novels saved from a site keep the page's <div>, <br/> and &nbsp; in the
TXT. Escaped, the tags were drawn as text and the chapter ran together
as one paragraph. cleanTextMarkup turns block tags into line breaks,
drops inline tags, decodes common entities and treats whitespace-only
lines as blank, before paragraphs and the chapter heading are inferred.
Only real tag names match; other markup is still escaped as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 05:51:18 +00:00

63 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-display  1.375rem  页面大标题、空状态标题
--text-title    1.05rem   屏幕标题、卡片标题;也是输入框的下限(见下)
--text-body     0.95rem   阅读侧正文与按钮
--text-ui       0.875rem  应用界面的列表行、设置行(影音正文)
--text-label    0.82rem   字段标签、区块标题、次要按钮
--text-meta     0.75rem   标题下方的元信息:数量、时间、来源
--text-micro    0.625rem  封面与图标上的角标,别处不用

按角色命名而非按尺寸,因为尺寸会变、角色不会:--text-meta 永远是「标题下面 那行」,无论今天是 0.72rem 还是明天 0.75rem。(它确实从 0.72rem 改成了 0.75rem: 影音界面 200 多处 12px 的元信息因此原样落档,阅读侧的元信息只大了半个像素。)

迁移前四档只约束了阅读侧,影音样式表里 8–24px 每个整数都用过。现在 24px 以下的字号 必须用令牌,web/test/design-tokens.test.ts 会拒绝字面量;24px 以上是装饰性大字 (播放页的首字、大数字),保留字面量。

输入框至少 16px:更小的字号会让 iOS Safari 在聚焦时放大视口。需要这条下限的输入框 用 --text-title,不要退到 --text-body。

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,看起来 像坏了,而不是像有反馈。

test/design-tokens.test.ts 检查 src/ 下每条带 :hover 的规则都在这样一个块里。 .x:hover, .x:focus-visible 这类混写要拆开::focus-visible 留在外面(键盘在触屏 设备上也要有反馈),:hover 那一半紧跟其后放进守卫块,层叠顺序不变。

2.6 圆角:六档

--radius-xs    4px    角标、标签、进度条
--radius-sm    8px    小控件、菜单项、缩略图、封面
--radius-md    10px   按钮、输入框、菜单、列表卡片(= --reader-radius)
--radius-lg    14px   面板、页面级卡片、浮层
--radius-xl    20px   底部 Sheet、大卡片
--radius-pill  999px  胶囊:芯片、开关、圆角等于半高的按钮

圆形用 50%,直角用 0,这两个不算档位。迁移前样式表里有 2/3/4/5/6/7/8/9/10/11/12/13/14/15/18/19/20/22/24px 外加十几个 rem 值,同一类控件在相邻两屏差 1–2px,单独看都对,放在一起就是两套界面。

胶囊要写 pill,不要写半高。 height: 26px; border-radius: 13px 在高度改成 28px 的那天 就不再是胶囊了;--radius-pill 与高度无关。

2.7 无封面的占位

没有封面的书、影片按标题取一个色相(web/src/ui/cover-tint.ts,12 档、每档 30°), 写进 --cover-hue,样式表把 hsl(var(--cover-hue) 55% 55%) 以 10–20% 混进当前主题的 surface。这是「组件不写死颜色」的唯一例外,理由与封面图片相同:它是内容的颜色,不是界面的颜色。

  • 按标题取,不按位置取:同一本书在书架、书库、搜索里颜色一致,刷新也不变。
  • 只混少量:占位上的文字仍用 ink/muted,对比度由主题保证;深色模式下自然变成暗底微染。
  • 不加色相之前,一屏全是同一个色块,只能逐个读字找书。

3. 布局

3.1 安全区

四个 --safe-* 令牌在 :root 定义一次,各处只引用不直接读 env()。 两个理由,第二个才是关键:一个边一个名字,意味着手机、浏览器和 Android 壳不会 各自对「屏幕从哪开始」有略微不同的理解;而且未定义的自定义属性会让整个 calc() 失效并静默丢弃声明——padding: calc(0.9rem + var(--safe-left)) 在变量未设置时不是「0.9rem 的内边距」,而是完全没有内边距。

谁贴着哪条边,谁加哪个安全区。 只有真正贴着屏幕边的那一层加,而且只加一次:

  • 顶边:贴顶的页头加 --safe-top(书架/书库 .shelf-head、.collection-header, 书源 .sources-header,手机上的影音页 .media-screen,首页与我的,阅读器顶栏与页眉)。
  • 底边:手机底栏 .app-nav、Sheet 的底部、阅读器底栏与页脚加 --safe-bottom; 浮在底部的东西(Notyf、.pwa-update 更新条、迷你播放器)放在这些栏的上方, 偏移量与 styles/notifications.css 一致,不能盖住导航或阅读器底栏。
  • 左右边(横屏刘海):宽度 ≥701px 时手机横屏也会进入侧栏布局。侧栏贴左边, 由它加 --safe-left;内容列贴右边,由 .app-shell-body 用 padding-right 统一 承担右侧安全区,并在内部把 --safe-left / --safe-right 置为 0——内容列里各条带 自己也会加安全区,不置 0 就会被缩进两次,或被一个它根本不挨着的刘海缩进。 阅读器不在外壳里,它的顶栏和底栏左右各加 --safe-left / --safe-right。
  • 一条规则同时写四个方向的 padding 时,核对左右没有写反(.shelf-head 曾经把 --safe-right 加在左边)。

iOS 独立窗口用 apple-mobile-web-app-status-bar-style: default:页面从状态栏下方开始, 竖屏 --safe-top 为 0。black-translucent 会让状态栏文字恒为白色,在默认浅色主题上 看不见,所以不用;上面的顶边规则仍然保留,给会报告顶部安全区的浏览器和方向用。 Android 壳在原生层处理系统栏(MainActivity.applyInsets),WebView 里四个值都是 0。

验证安全区不用真机:Chromium 的 CDP Emulation.setSafeAreaInsetsOverride 可以给 env(safe-area-inset-*) 填任意值。改动贴边的条时,至少用竖屏(上 59 / 下 34)和 横屏(左右 59 / 下 21)各看一遍。

3.2 一列,封顶

首页、书架、影音、我的共用一条内容列:内容宽度封顶 --page-measure(--collection-measure 80rem 减去书架带内边距,约 1251px), 两侧留白 --page-gutter(手机 16px,侧栏模式 32px),切换标签页时内容边缘不动。宽屏加的是留白而不是更多列。 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):小于这个数的控件在 拇指上不是「偏小」,而是需要瞄准。

在 (pointer: coarse) 设备上,外观必须小的控件用透明的 ::before 把命中区扩到 约 44px,外观不变:筛选/排序芯片 .chip(29px 高;水平方向只扩一点,免得相邻芯片 互相抢点击)、封面角上的 ⋯(.book-more)和「加入书架」(.book-shelve)。

手指能点到的输入框,字号至少 16px(--text-title)。iOS 不理会 maximum-scale=1,聚焦小于 16px 的输入框会把整页放大;侧栏的全局搜索在 (pointer: coarse) 下因此改用 --text-title。

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 弹层:只有一种 Sheet

所有设置、编辑表单、选择列表、详情和确认框都用 web/src/ui/sheet.tsx 的 Sheet (确认框是 ConfirmSheet),样式只在 web/src/styles/sheet.css。不再使用居中弹窗、 SweetAlert、Mantine Modal、自绘遮罩或角落浮动卡片。

视口 形态
≤700px 底部 Sheet:圆角顶边、把手,高度随内容,最高到安全区下方;阅读器内最高 62%
≥701px 右侧抽屉:距边 1rem,宽 30rem;size="wide" 宽 42rem 给复杂表单
≥701px,size="compact" 确认、单行输入、短选择列表:跟手,出现在触发它的点按位置或聚焦控件的下方(放不下则上方),宽 24rem,始终留在窗口内;找不到触发点时回到右上角
  • 原生 <dialog> + showModal():顶层、背景惰性、焦点限制在内部,关闭后焦点回到打开它的控件。
  • 三个出口始终相同:标题栏 ✕(「关闭」)、Esc、点背景;busy 时三者都禁用。
  • 就地渲染、不走 portal,所以继承打开它的页面主题(阅读器黑白灰、影音别名)。
  • 调用方的 className 只管内容样式,不得设置位置、尺寸、圆角、背景遮罩或标题栏。
  • 确认框焦点默认在「取消」;破坏性操作传 danger,按钮用危险色。
  • ⋯ 操作菜单和单选筛选(收藏类型等)用 web/src/ui/action-menu.tsx 的 ActionMenu:从触发按钮下方弹出的下拉菜单(手机和桌面一致),没有标题栏和遮罩,选中、Esc、Tab 或点外部即关闭,支持方向键;样式在 web/src/styles/action-menu.css。需要以问题形式出现的单选(书架的选择对话框)用 ActionSheet,即 compact Sheet 里的同一列菜单项。动作是数据(label、onSelect、danger、checked),菜单先关再执行动作,所以动作可以再开一个 Sheet 或跳转。不再使用 Mantine Menu 或 <details> 下拉。
  • 锚定在控件上的只有 ActionMenu 下拉、选择框(MediaSelect、SearchableSelect)和正文选区工具条,它们不是 Sheet。

以下是阅读器面板最初改成底部半屏的理由,仍然适用于手机形态:

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

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

三条配套,都不是装饰:

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

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

3.8 事件层级

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

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

翻页模式下页面跟手。 手指明显横向移动(超过 12px 且横向大于纵向)后, ReaderView.beginDrag 开始让分栏带的 scrollLeft 跟着手指走,看到的就是真正的下一页; 松手时由 onRelease 决定:快速划过(≥48px 且 <800ms)或拖过页宽 30% 就滑到相邻页, 否则弹回原页,两种都从手指离开的位置继续滑完(约 200ms,翻页动画为「无」或系统要求 减少动效时直接到位)。本章没有那一页时立即复位,交给普通翻页去开下一章。跟手期间 snapStrayScroll 不吸附。鼠标拖动仍是选字;滚动模式的纵向拖动本来就是浏览器滚动; 固定版式(漫画)和从右到左排版不跟手。onRelease 返回 true 表示这次手势已由拖动 处理,不再当作滑动或点按。

影音分类的横滑(shared/ui/use-swipe-tabs.ts、swipeCatalogTarget)按分类一格一格走, 到两端停住,不循环。某个频道一个媒体库都没有时,它的分类页都是同一个空页、也不显示 分类标签,逐格滑看起来像没反应,所以改为按频道走(MediaPageFrame emptyChannel)。 频道由路径决定(useMediaChannel),静态路由(audiobook/narrators)也一样; 「演播者」是有声书的第二个分类,和「有声书」同级,保留底部导航栏。

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

  • 必须用 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,否则正文会变成 第二章 落雨雨来了。)。 分段之前先过 cleanTextMarkup:从网页另存的 TXT 常带着 <div>、<br/>、 &nbsp;,原样转义就会把标签画成文字、每个 <br/> 行都成一段空白。块级标签 (br/p/div/标题/列表项)换成换行、行内标签(span/font/b…)去掉、 常见实体解码、只有空白的行算空行;只认真实的标签名,正文里的 a<b、<书名> 不动,其余标签照旧转义成文字。 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-page-trim,整章一个值,页数由它算);但段间距和标题会让后面的行偏离这个网格,所以每翻一页还要把底边切过的那一行用 clip-path 遮住(--reader-page-mask,ReaderView.syncPageMask)。下一页从这一行的行首开始,不会漏字。遮罩不改变 clientHeight,所以页数和翻页位置都不受影响;翻页动画期间不测量(行框还带着 translateY),动画结束后再测。
  • 四个面板使用统一底部半屏布局;外观放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
  • “外观”面板按使用频率排序:纸色(跟随主题、白纸、暖黄、浅绿、灰调、夜间)、字号、行距、主题(阅读主题:“跟随应用”或黑白灰,用各主题真实的底色与墨色绘制的色块)、明暗(跟随系统/浅色/深色)、亮度排在首屏;“更多排版”(页边距、对齐、字体,以及 TXT 的缩进、段间距、编码)和“页眉页脚”(四个阅读信息)收进默认折叠的区块。漫画不显示文字排版,只保留主题、明暗、亮度和“页眉页脚”。
  • 纸色是阅读页单独的设置(AppSettings.paper,按设备保存,与字号一样),只改页面的底色、文字和页眉页脚(.reader-screen[data-paper] 覆盖 --reader-paper/ink/muted/line),工具栏和面板仍用阅读主题。夜间纸色让阅读器整体变深;浅色纸色在应用为深色时默认让位给主题的深色取值,可在“跟随系统深色模式”开关关掉。外观与主题页也提供同一设置。V3 之前的 sepia/green/dark 页面主题首次读取时迁移成暖黄/浅绿/夜间。
  • TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
  • 阅读主题默认“跟随应用”;也可选阅读专用的黑白灰,只写在阅读器容器上(data-theme="gray"),浅色和夜间跟随应用的明暗。日夜快捷按钮把明暗设为浅色或深色(离开“跟随系统”),保留当前应用主题。电纸书预设使用黑白灰浅色。漫画隐藏文字排版选项。

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. 暗色与主题

主题体系见《Reader V3 主题语义令牌》(v3.1):暖陶(默认)、雾蓝、青绿三个应用主题,加阅读专用的黑白灰,每个都有浅色和深色两套取值。

  • 组件只读 --color-* 语义令牌(web/src/styles/theme-tokens.css)。<html> 上的 data-theme 与 data-mode 选中一套取值;一个容器可以带自己的这对属性而穿另一套主题(阅读器的黑白灰、视频和音乐播放页的深色沉浸)。
  • brand 和 ch-* 只作填充,上面的字用 on-brand/on-ch;品牌色的文字、链接、选中图标用 brand-text(旧名 --reader-accent、--media-accent),填充用 --reader-fill/--media-fill。选中的标签和分段控件用 brand-soft 底加 brand-text 字。
  • TypeScript 镜像在 web/src/shared/theme/theme.ts(预览色块、对比度);test/theme-tokens.test.ts 保证两边取值一致,并按规范 §5 的组合检查 8 套配色的对比度。新增或调色时两边一起改。
  • 偏好按服务器和账号存在本机(reader.theme.v2:<scope>):应用主题、明暗(跟随系统/浅色/深色)、跟随系统时浅色和深色各用哪个主题、阅读主题。第一次读取时把 V3 的六套主题按规范 §8 迁移(蔷薇回落到暖陶并保留浅色)。
  • 主题只改界面的颜色。书的颜色由书自己决定——一个硬编码 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 和 刷新这三件事上分别坏一次,而这三件事没有一条是功能测试会先撞上的。
  12. 动贴边的条(页头、底栏、侧栏、浮在底部的提示)→ 按 §3.1 用模拟安全区看一遍 竖屏和横屏。ui:review 不模拟安全区,刘海下的按钮在它的截图里是看不出来的。

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/sheet.tsx / web/src/styles/sheet.css 唯一的弹层 Sheet 与 ConfirmSheet(§3.7)
web/src/ui/dialog.tsx 提问(文本框/确认/选择),基于 Sheet,书架与文件管理共用
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 外壳与导航

  • 一个导航:手机底部四个标签(首页、书架、影音、我的),701px 以上改为左侧栏,影音下列出影视、音乐、有声书三个频道。取代旧的媒体频道栏。
  • 导航按页面类型显示:顶层页面显示;详情、设置、书库文件等二级页面在手机上由自己的返回箭头取代底栏,桌面端侧栏保留;阅读器和播放页不显示。规则在 web/src/app/AppNav.tsx 的 navMode。
  • 主题在应用壳层统一应用(ThemeController 写 <html data-theme data-mode>)。登录页在应用主题之前显示,使用暖陶并跟随设备深浅色。书架、书库、书源、首页、阅读器的 --reader-* 是 --color-* 的别名(theme-tokens.css),影音页的 --text、--media-* 别名在 presentation.css 顶部。
  • 频道色(阅读、影视、音乐、有声书)是每个主题自己的 --color-ch-*(填充)、-soft(卡片底)和 -text(文字),由 theme-tokens.test.ts 守住对比度。
  • 首页显示在读的书、未看完的影视和有声书、书架一行;我的页只做入口,不拥有任何设置。
  • “跟随系统”= 浅色时用的主题加深色时用的主题(默认都是暖陶),在外观与主题页指定;关掉跟随系统时保留屏幕上当前的主题与明暗,再选浅色或深色。读取与保存只走 web/src/shared/theme/theme.ts 的 readThemePreference / saveThemePreference;判断当前主题一律用 resolveAppTheme / resolveReadingTheme,React 里用 useThemeState,不要自己读存储。
  • 搜索:桌面侧栏有“搜索书名、片名、歌曲、有声书”,打开 #/search?q=,同时列出书库里的书和有权访问的影音,各自链接到完整的书库搜索和影音搜索。侧栏账号下方显示同步引擎对服务器的判断:已连接、正在同步、离线或同步出错。
  • 书架:搜索框下是“全部 / 在读 / 想读 / 已读”和“分组”(按书的系列或标签筛选),都写进 URL(?status=、?series=、?tag=)。封面左上角标出在读、想读、已读;“⋯”菜单可标记想读、在读、已读或清除标记,并保留“从书架拿掉”。状态以手动标记为准,没有标记时按进度推断。
  • 视频播放:字幕、音轨、版本是同一个“字幕与音轨”抽屉的三个分页。字幕页列出关闭、外挂(服务器上的 srt/ass/vtt)、内嵌,可“添加外挂字幕”(本机文件在浏览器里转换为 WebVTT,仅本次播放)、以 0.5 秒调整字幕延迟(外挂与本机字幕),并选择字号(小/标准/大,按设备保存)。工具栏的“全屏”进入横屏全屏:选集、倍速、适应屏幕(铺满裁边)、字幕和锁定浮在画面上,随 DPlayer 控制条一起淡出;锁定后点击不再作用于播放器,只留解锁按钮。这些只在网页播放器提供,Android 播放器用自己的菜单。
  • 媒体库管理:每个媒体库卡片显示最近一次扫描——完成时间和“重新扫描”、进行中的进度(“已处理 62%”)和“取消”、失败原因和“重试”“查看详情”。右侧“扫描与元数据”是服务器业务配置里的四个开关,逐项保存。
  • 播放页的循环模式与定时:音乐的传输行是“循环模式、上一首、播放、下一首、队列”,有声书仍是快退、播放、快进,没有播放模式。循环模式按钮依次切换顺序播放、随机、单曲循环;播放队列面板用分段控件给出同一选择(web/src/features/media/components/play-mode.tsx,类型与文案在 services/play-mode.ts)。随机在所有条目播过一遍前不重复,上一首沿已播顺序回退;单曲循环不受“连续播放”偏好影响,手动下一首仍按队列顺序。睡眠定时抽屉在分钟档位之外提供“播完本章/本曲/本集”:当前条目结束后停下一次并保留进度,与分钟定时互斥,选中项带按下态,触发按钮显示“播完本曲”等。模式与播完即停只在网页播放器可用——Android 原生播放服务没有对应命令,此时循环按钮置灰、队列面板不显示分段控件、“播完…”置灰并说明原因。视频控制与设置页里的睡眠定时暂未接入“播完即停”。