mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
Route login, shelf, library, file operations, reading, sources and extension feedback through one compact themed notification host. Keep native modal feedback in the top layer, escape remote text, update progress in place and let reading controls receive clicks beneath message text. Cover lifecycle, error handling and layout with unit and Chromium checks.
723 lines
48 KiB
Markdown
723 lines
48 KiB
Markdown
# 界面规范
|
||
|
||
这份文档规定**界面长什么样、为什么**。它约束的是客户端的外观层,不涉及排版
|
||
(书的排版见 `architecture.md` §8 —— 那里的原则相反:对书不动手)。
|
||
|
||
之所以需要一份成文规范:这个客户端的界面层不是一个人一次写完的,而是被反复
|
||
改出来的。每一次改动单独看都合理,合起来就成了「这个控件 0.35rem、那个 0.6rem,
|
||
这个图标是 Unicode 字符、那个是 emoji」。规范的作用是让「再改一处」这件事有一个
|
||
可以对照的基准。
|
||
|
||
---
|
||
|
||
## 1. 图标
|
||
|
||
图标统一使用 `lucide-preact` 的 SVG,`src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。
|
||
界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。
|
||
装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。
|
||
|
||
## 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.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 手机阅读
|
||
|
||
- 顶部:返回、书名、当前章节。
|
||
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
|
||
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
|
||
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
|
||
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
|
||
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
|
||
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
|
||
- 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。
|
||
|
||
### 3.11 朗读
|
||
|
||
朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。
|
||
“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。
|
||
|
||
## 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` 做两件事,两件都要:
|
||
|
||
1. **截图**(`docs/ui-review/*.png`)。这一步没有断言可以替代。「只是丑」不是
|
||
测试失败,但它是缺陷。截图交给评审的人看。
|
||
2. **量尺寸**(同一脚本,断言)。可以量的事情不该让人用眼睛判断,而且评审的人
|
||
不在流水线里。当前量的是:顶栏/底栏/快捷列三条 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 跟着换。
|
||
加入/移除补全之后又加了一条(§3.5.3):**每张封面上的「⋯」按得着也看得见**——
|
||
量的是命中区(≥28px,和顶栏控件同一个下限)和它**在卡片之内**,外加两条反
|
||
命题:打开区仍然覆盖整张卡(放大字形不能吃掉开书的那块面积),以及静止不透明度
|
||
不低于 0.5(一个量得到、但看不见的按钮,功能测试全绿)。
|
||
|
||
它跑在**真实的生产产物**上:`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`,**键名用这个项目自己的英语词**
|
||
(§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/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-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-screen.test.ts` | 书架排序(默认项、`recent` 是客户端排)、分页的算术、超界怎么夹 |
|
||
| `web/test/shelf-order.test.ts` | 排序的方向、客户端/服务端的键、阅读时间排序与并列时的稳定 |
|
||
| `web/test/library-screen.test.ts` | 浏览页:封面网格、文件夹作用域的搜索、「加入书架」发的是哪个书 id |
|
||
| `web/test/library-access.test.ts` | 文件管理页的管理员判定(§3.4.4):路由词汇,以及非管理员拿到的是浏览页 |
|
||
| `web/test/manager.test.ts` | 书库文件页的分页、上传后的重读、「破坏性操作必须先选中再确认」,以及**这一页没有上架/下架**(§3.5.1) |
|
||
| `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` |
|