mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 05:20:54 +08:00
722 lines
48 KiB
Markdown
722 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 悬浮提示,不占位
|
||
|
||
状态行(`同步中…`、`离线 · 进度待同步`)是**浮在正文上方的药丸**,不是
|
||
插在顶栏和正文之间的横条。
|
||
|
||
这不是审美。横条在 flex 列里占一行,于是每一次状态变化都会把正文往下推一行
|
||
再收回来——读者正在读的那句话会跳。一条他没要过的提示挪动他正在读的字,是
|
||
这个界面里最不该发生的事。`ui:review` 里有一条断言专门盯着这个:把状态行
|
||
从空切到有字再切回来,正文顶部的 y 必须三次相同。
|
||
|
||
同理,提示不吃指针事件(`pointer-events: none`):一条挡住读者本来要点的
|
||
那一下的提示,代价是读者少翻一页。
|
||
|
||
### 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` |
|