merge: 同步远程阅读界面更新并保留书源配置样式

This commit is contained in:
hectorqin committed 2026-09-21 09:39:39 +08:00
commit 59faec336a
29 files changed
+727 -2291

No files matched your search

+1
View File
@@ -26,3 +26,4 @@ web/dist/
# every run is noise in a review. The *report* is committed — that is the part a
# reviewer reads, and the part CI asserts on.
docs/ui-review/*.png
docs/ui-review/mobile/*.png
+6 -14
View File
@@ -95,7 +95,7 @@ volumes:
| 页边距 | 0–4rem,控制每行字数 |
| 对齐 | 默认「原书」,可强制左对齐 / 两端对齐 |
| 字体 | 默认「原书」。可选系统黑体 / 宋体 / 楷体 / 苹方,作为字体栈的**前缀**而不是替换 |
| 主题 | 白 / 米黄 / 夜间 |
| 主题 | 白 / 米黄 / 浅绿 / 夜间 |
| 亮度 | 35%–100%,叠加式调暗,文字与纸色的对比关系不变 |
| 图片适配 | 仅固定版式(漫画、PDF):完整显示 / 适宽 |
| 翻页方向 | 仅固定版式:左→右 / 右→左(日漫) |
@@ -108,20 +108,12 @@ volumes:
### 界面与图标
界面图标是**一个自带的字体**(`web/src/styles/reader-icons.ttf`,37 个字形,16KB),
不是 Unicode 字符,也不是内联 SVG:
阅读页以手机浏览器为优先:顶部显示返回、书名和章节,底部集中目录、朗读、界面、设置。
工具栏和半屏面板覆盖正文,显隐不改变正文区域尺寸。界面设置可分别选择顶部左/右、底部左/右显示书名、章节名、章节进度、时间或不显示;默认顶部左侧章节名、底部左侧章内页码与全书百分比、底部右侧时间,按设备保存。
底栏上方保留一个日夜切换按钮,随工具栏一起收起。
- **不是 Unicode 字符**:改动前按钮里放的是 `☰` `⚙` `✕` `🗂` `📁` `⏮`。
八个字形来自八个字族,光学重量各不相同;其中几个是 emoji,在部分平台被渲染成
彩色的——一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的信号。
不同平台的字体回退还不同,同一个界面在两个宿主上长得不一样。
- **不是内联 SVG**:三十个小组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的
位置,而字体把字形变成**字符**——它没有自己的几何,也就没有可以漂移的地方。
- 字形由 `web/tools/icons/` 下的脚本从**描边路径**编译而成,全部画在同一个
24 单位网格上、同一条线宽,这是光学一致性的来源。重新生成:`npm run icons`。
界面规范(图标、间距/字号/动效令牌、焦点环、状态表达、文案)见
[`docs/ui.md`](docs/ui.md)。
图标使用 Lucide SVG,通过统一的 Icon 组件控制尺寸、线宽和无障碍标签,不再加载字体图标。
界面规范见 [docs/ui.md](docs/ui.md),手机回归可运行 `cd web && npm run ui:review`。
### 翻页与章节导航
+7 -48
View File
@@ -6,8 +6,8 @@
| 截图 | 场景 | 看什么 |
| --- | --- | --- |
| `01-shelf.png` | 书架 | 第一屏:先看到书,再看到控件 |
| `02-reader.png` | 阅读页 | 顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数 |
| `02-reader.png` | 阅读页 | 顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口 |
| `03-reader-no-chrome.png` | 阅读页 · 收起工具栏 | 工具栏一起收起,正文保持原位置,底部只留书名、页码与进度 |
| `04-panel-toc.png` | 目录 · 半屏 | 下半屏,上半屏正文仍可见 |
| `05-panel-settings.png` | 阅读设置 · 半屏 | 一行式行:标签在左、控件在右 |
| `06-reader-paged.png` | 阅读页 · 翻页模式 | 分栏后的一页,页数应与可翻次数一致 |
@@ -27,49 +27,8 @@
| 检查 | 结果 | 说明 |
| --- | --- | --- |
| 展开工具栏: 快捷按钮与两条栏一起出现 | ✅ | visible=true 按钮 3 个 pointer-events=auto |
| 快捷列: 整组控件都在页内 | ✅ | 列 y=186..335 / 屏高 844 |
| 快捷列: 装不下的控件有滚动条,说明还有更多 | ✅ | 溢出 否 overflow-y=true 滚动条 0px |
| 快捷列: 只留页面内就能完成的调整 | ✅ | 按钮 3 个:日间 / 字号 / 听书 |
| 快捷列: 不含打开面板的五个入口 | ✅ | 列内控件:日间 / 字号 / 听书 |
| 快捷列: 听书在列内 | ✅ | 列内控件:日间 / 字号 / 听书 |
| 快捷列(420px 短屏): 列在页内,超出的控件能滚到 | ✅ | 列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true |
| 收起工具栏: 顶栏与底栏一起走 | ✅ | state=hidden 顶栏 hidden / 底栏 hidden |
| 收起工具栏: 两条栏都不可见,也不可点 | ✅ | topbar=none footer=none |
| 收起工具栏: 正文只是让出读数行的高度,没有被重新排版 | ✅ | 正文首行 y: 显示时 24 / 收起后 49,读数行高 25px |
| 正文铺满: 页面不为工具栏预留高度 | ✅ | padding 0/0px, 页面 844px / 屏高 844 |
| 工具栏是浮动层,不占正文的高度 | ✅ | topbar=absolute footer=absolute |
| 收起工具栏: 快捷按钮跟着两条栏一起收 | ✅ | visible=false pointer-events=none |
| 收起工具栏: 保留章节与页码指示 | ✅ | chapter="第一章 惊蛰" progress="第 1/3 页 0%" |
| 收起工具栏: 阅读指示不吃手势 | ✅ | pointer-events=none |
| 收起工具栏: 章节名画在正文之上 | ✅ | 层级 30 vs 正文 1,y=0..25 文本="第一章 惊蛰" |
| 收起工具栏: 章节名自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
| 收起工具栏: 页码读数画在正文之上 | ✅ | 层级 30 vs 正文 1,y=820..844 文本="第 1/3 页 0%" |
| 收起工具栏: 页码读数自带底色,不被正文透出 | ✅ | background=rgb(22, 23, 26) |
| 收起工具栏: 章节名与页码左边对齐 | ✅ | 章节名 left=0 页码 left=0 |
| 收起工具栏: 章节名占满一整行并紧贴顶部 | ✅ | 行 390px / 屏宽 390,top=0 |
| 收起工具栏: 页码占满一整行并紧贴底部 | ✅ | 行 390px / 屏宽 390,bottom=844/844 |
| 点右侧只翻页: 页码前进, 工具栏保持收起 | ✅ | 收起前 hidden(第 1/3 页 0%) → 点击后 hidden(第 2/3 页 40%) |
| 收起工具栏: 翻页后正文不被读数行拦腰截断 | ✅ | 读数行 [[0,25],[820,844]],没有半截行 |
| 点中间切换工具栏 | ✅ | 点击中间后 visible |
| 翻页: 动画期间不丢滚动位置(动画后不再跳页) | ✅ | 目标 390 → 动画中 390 → 动画后 390,overflow=hidden |
| 目录: 面板不遮住整页 | ✅ | 面板从 y=321 开始,正文区 y=0 |
| 目录: 面板留出可预览的正文 | ✅ | 正文可见高度 321px |
| 目录: 面板有遮罩和把手 | ✅ | scrim=true grip=true |
| 目录: 面板高度不超过阅读区 | ✅ | 面板 523px / 阅读区 844px |
| 顶栏: 三个按钮左右两边对齐 | ✅ | 左 12px / 右 12px,两道间隙 136px / 136px |
| 底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐 | ✅ | 页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px |
| 顶栏与底栏共用同一条内边距 | ✅ | 顶栏 12px / 底栏 12px |
| 底栏: 进度是可拖动的滑杆 | ✅ | type=range 宽 308px label="章节内页数" |
| 底栏: 有页码与阅读进度读数 | ✅ | page="第 1/3 页" nav="阅读进度:0%" |
| 底栏: 滑杆量的是章节内页数,不是全书百分比 | ✅ | min=1 max=3 value=1 读数="第 1/3 页"(章节共 3 页) |
| 底栏: 拖动滑杆切到对应页 | ✅ | 拖到 3 后读数="第 3/3 页" |
| 翻页模式: 滑杆拖到第 3 页,仍在本章 | ✅ | min=1 max=3 value=3 拖前="第 3/3 页" 拖后="第 3/3 页" |
| 状态提示不推动正文 | ✅ | 正文顶部 y: 0 / 0 / 0 |
| 触控目标不小于 28px | ✅ | 8 个控件全部合格 |
| 每个控件都有可读的名字 | ✅ | 8 个控件全部有名字 |
| 没有横向溢出 | ✅ | document 溢出 0px |
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:1 次 POST / 0 次 GET(共 1 次 sync 请求) |
| 手机阅读交互回归 | ✅ | reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏 |
| 翻页不再每次都发两次 sync | ✅ | 8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求) |
| EPUB: 章节里的图片真的画出来了 | ✅ | naturalWidth=240 显示 240x160 src="/api/v1/books/review-illustrated/assets?__reader-book-resource__=1&ref=OEBPS%2FI" |
| EPUB: 插图下面的正文也在 | ✅ | 正文开头="台版 转自 天使动漫
@@ -78,7 +37,7 @@
| EPUB: 收窄插图没有改变它的比例 | ✅ | 原图 8.750 / 画出来 8.752 |
| EPUB: 宽插图没有让页面横向溢出 | ✅ | document 溢出 0px / 阅读面溢出 0px |
| 搜索框: 两边都从封面列内缩,且左右相等 | ✅ | 左 14px / 右 14px(字段 0 则为贴着封面) |
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px) |
| 搜索框: 放大镜与输入框中线对齐 | ✅ | 放大镜中线 98.1 / 输入框中线 98.1(偏差 0.0px,字形 16.796875px,框高 44.203125px) |
| 书架: 第一个排序是「最近阅读」且默认选中 | ✅ | chips=最近阅读* / 最近入库 / 书名 / 作者 |
| 书架: 「最近入库」仍然在排序里 | ✅ | chips=最近阅读 / 最近入库 / 书名 / 作者 |
| 书架: 排在最前的是最近读过的那本 | ✅ | 前两本:剑来 第1卷 / 剑来 第2卷 |
@@ -98,7 +57,7 @@
| 书库: 文件管理页没有「下架 / 从书架拿掉 / 加入书架」任何一处 | ✅ | 页面文本命中=无 |
| 书库: 文件管理页仍然标出不在书架的书(徽章不是控件) | ✅ | 徽章=true |
| 书库: 从文件管理回到浏览是换页,URL 也跟着换 | ✅ | 浏览=true 文件=false hash=#/library 封面 60 张 |
| 书架标题栏: 两个图标都画出来了,且不超出各自的按钮 | ✅ | 书库: 18.4×18 in 40 box, inside=true | 书架设置 · 适中: 18.4×18 in 40 box, inside=true |
| 书架标题栏: 图标不比标题大(同一行的视觉重量) | ✅ | 图标 18 vs 标题 28 | 图标 18 vs 标题 28 |
| 书架标题栏: 两个图标都画出来了,且不超出各自的按钮 | ✅ | 书库: 18.4×18.4 in 40 box, inside=true | 书架设置 · 适中: 18.4×18.4 in 40 box, inside=true |
| 书架标题栏: 图标不比标题大(同一行的视觉重量) | ✅ | 图标 18.4 vs 标题 28 | 图标 18.4 vs 标题 28 |
全部通过。
+8 -213
View File
@@ -12,12 +12,12 @@
{
"name": "02-reader",
"label": "阅读页",
"what": "顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章"
"what": "顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口"
},
{
"name": "03-reader-no-chrome",
"label": "阅读页 · 收起工具栏",
"what": "顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数"
"what": "工具栏一起收起,正文保持原位置,底部只留书名、页码与进度"
},
{
"name": "04-panel-toc",
@@ -92,219 +92,14 @@
],
"results": [
{
"name": "展开工具栏: 快捷按钮与两条栏一起出现",
"name": "手机阅读交互回归",
"ok": true,
"detail": "visible=true 按钮 3 个 pointer-events=auto"
},
{
"name": "快捷列: 整组控件都在页内",
"ok": true,
"detail": "列 y=186..335 / 屏高 844"
},
{
"name": "快捷列: 装不下的控件有滚动条,说明还有更多",
"ok": true,
"detail": "溢出 否 overflow-y=true 滚动条 0px"
},
{
"name": "快捷列: 只留页面内就能完成的调整",
"ok": true,
"detail": "按钮 3 个:日间 / 字号 / 听书"
},
{
"name": "快捷列: 不含打开面板的五个入口",
"ok": true,
"detail": "列内控件:日间 / 字号 / 听书"
},
{
"name": "快捷列: 听书在列内",
"ok": true,
"detail": "列内控件:日间 / 字号 / 听书"
},
{
"name": "快捷列(420px 短屏): 列在页内,超出的控件能滚到",
"ok": true,
"detail": "列底 242 / 屏高 420,滚动条 0px,滚到底后末个控件可见=true"
},
{
"name": "收起工具栏: 顶栏与底栏一起走",
"ok": true,
"detail": "state=hidden 顶栏 hidden / 底栏 hidden"
},
{
"name": "收起工具栏: 两条栏都不可见,也不可点",
"ok": true,
"detail": "topbar=none footer=none"
},
{
"name": "收起工具栏: 正文只是让出读数行的高度,没有被重新排版",
"ok": true,
"detail": "正文首行 y: 显示时 24 / 收起后 49,读数行高 25px"
},
{
"name": "正文铺满: 页面不为工具栏预留高度",
"ok": true,
"detail": "padding 0/0px, 页面 844px / 屏高 844"
},
{
"name": "工具栏是浮动层,不占正文的高度",
"ok": true,
"detail": "topbar=absolute footer=absolute"
},
{
"name": "收起工具栏: 快捷按钮跟着两条栏一起收",
"ok": true,
"detail": "visible=false pointer-events=none"
},
{
"name": "收起工具栏: 保留章节与页码指示",
"ok": true,
"detail": "chapter=\"第一章 惊蛰\" progress=\"第 1/3 页 0%\""
},
{
"name": "收起工具栏: 阅读指示不吃手势",
"ok": true,
"detail": "pointer-events=none"
},
{
"name": "收起工具栏: 章节名画在正文之上",
"ok": true,
"detail": "层级 30 vs 正文 1,y=0..25 文本=\"第一章 惊蛰\""
},
{
"name": "收起工具栏: 章节名自带底色,不被正文透出",
"ok": true,
"detail": "background=rgb(22, 23, 26)"
},
{
"name": "收起工具栏: 页码读数画在正文之上",
"ok": true,
"detail": "层级 30 vs 正文 1,y=820..844 文本=\"第 1/3 页 0%\""
},
{
"name": "收起工具栏: 页码读数自带底色,不被正文透出",
"ok": true,
"detail": "background=rgb(22, 23, 26)"
},
{
"name": "收起工具栏: 章节名与页码左边对齐",
"ok": true,
"detail": "章节名 left=0 页码 left=0"
},
{
"name": "收起工具栏: 章节名占满一整行并紧贴顶部",
"ok": true,
"detail": "行 390px / 屏宽 390,top=0"
},
{
"name": "收起工具栏: 页码占满一整行并紧贴底部",
"ok": true,
"detail": "行 390px / 屏宽 390,bottom=844/844"
},
{
"name": "点右侧只翻页: 页码前进, 工具栏保持收起",
"ok": true,
"detail": "收起前 hidden(第 1/3 页 0%) → 点击后 hidden(第 2/3 页 40%)"
},
{
"name": "收起工具栏: 翻页后正文不被读数行拦腰截断",
"ok": true,
"detail": "读数行 [[0,25],[820,844]],没有半截行"
},
{
"name": "点中间切换工具栏",
"ok": true,
"detail": "点击中间后 visible"
},
{
"name": "翻页: 动画期间不丢滚动位置(动画后不再跳页)",
"ok": true,
"detail": "目标 390 → 动画中 390 → 动画后 390,overflow=hidden"
},
{
"name": "目录: 面板不遮住整页",
"ok": true,
"detail": "面板从 y=321 开始,正文区 y=0"
},
{
"name": "目录: 面板留出可预览的正文",
"ok": true,
"detail": "正文可见高度 321px"
},
{
"name": "目录: 面板有遮罩和把手",
"ok": true,
"detail": "scrim=true grip=true"
},
{
"name": "目录: 面板高度不超过阅读区",
"ok": true,
"detail": "面板 523px / 阅读区 844px"
},
{
"name": "顶栏: 三个按钮左右两边对齐",
"ok": true,
"detail": "左 12px / 右 12px,两道间隙 136px / 136px"
},
{
"name": "底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐",
"ok": true,
"detail": "页码右 12px / 下一章右 12px;上一章左 12px / 滑杆左 12px"
},
{
"name": "顶栏与底栏共用同一条内边距",
"ok": true,
"detail": "顶栏 12px / 底栏 12px"
},
{
"name": "底栏: 进度是可拖动的滑杆",
"ok": true,
"detail": "type=range 宽 308px label=\"章节内页数\""
},
{
"name": "底栏: 有页码与阅读进度读数",
"ok": true,
"detail": "page=\"第 1/3 页\" nav=\"阅读进度:0%\""
},
{
"name": "底栏: 滑杆量的是章节内页数,不是全书百分比",
"ok": true,
"detail": "min=1 max=3 value=1 读数=\"第 1/3 页\"(章节共 3 页)"
},
{
"name": "底栏: 拖动滑杆切到对应页",
"ok": true,
"detail": "拖到 3 后读数=\"第 3/3 页\""
},
{
"name": "翻页模式: 滑杆拖到第 3 页,仍在本章",
"ok": true,
"detail": "min=1 max=3 value=3 拖前=\"第 3/3 页\" 拖后=\"第 3/3 页\""
},
{
"name": "状态提示不推动正文",
"ok": true,
"detail": "正文顶部 y: 0 / 0 / 0"
},
{
"name": "触控目标不小于 28px",
"ok": true,
"detail": "8 个控件全部合格"
},
{
"name": "每个控件都有可读的名字",
"ok": true,
"detail": "8 个控件全部有名字"
},
{
"name": "没有横向溢出",
"ok": true,
"detail": "document 溢出 0px"
"detail": "reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏"
},
{
"name": "翻页不再每次都发两次 sync",
"ok": true,
"detail": "8 次翻页 + 6 秒静默:1 次 POST / 0 次 GET(共 1 次 sync 请求)"
"detail": "8 次翻页 + 6 秒静默:2 次 POST / 0 次 GET(共 2 次 sync 请求)"
},
{
"name": "EPUB: 章节里的图片真的画出来了",
@@ -339,7 +134,7 @@
{
"name": "搜索框: 放大镜与输入框中线对齐",
"ok": true,
"detail": "放大镜中线 97.7 / 输入框中线 98.1(偏差 -0.4px,字形 16px,框高 44.203125px)"
"detail": "放大镜中线 98.1 / 输入框中线 98.1(偏差 0.0px,字形 16.796875px,框高 44.203125px)"
},
{
"name": "书架: 第一个排序是「最近阅读」且默认选中",
@@ -439,12 +234,12 @@
{
"name": "书架标题栏: 两个图标都画出来了,且不超出各自的按钮",
"ok": true,
"detail": "书库: 18.4×18 in 40 box, inside=true | 书架设置 · 适中: 18.4×18 in 40 box, inside=true"
"detail": "书库: 18.4×18.4 in 40 box, inside=true | 书架设置 · 适中: 18.4×18.4 in 40 box, inside=true"
},
{
"name": "书架标题栏: 图标不比标题大(同一行的视觉重量)",
"ok": true,
"detail": "图标 18 vs 标题 28 | 图标 18 vs 标题 28"
"detail": "图标 18.4 vs 标题 28 | 图标 18.4 vs 标题 28"
}
],
"failures": []
+15 -294
View File
@@ -12,191 +12,9 @@
## 1. 图标
### 1.1 用字体,不用内联 SVG
图标以**字体**形式提供:`web/src/styles/reader-icons.ttf`,41 个字形,
18KB,随 bundle 一起走。
选择字体的理由不是「省事」,而是**一致性可以被强制**:
| | 字体 | 内联 SVG |
| --- | --- | --- |
| 颜色 | 继承 `color` | 每个组件各自写 `fill`/`stroke` |
| 尺寸 | 继承 `font-size`,跟着布局缩放 | 每个组件各自写 `width`/`height` |
| 线宽 | 一个(生成时定死) | 每个字形都可能不同 |
| 新增图标 | 加字符 | 加组件 |
第二列里的每一项都是「单个字形可以漂移」的位置。内联 SVG 集合最常见的失败
就是这个:三十个组件里总有几个的线宽或视觉重量跟其余不一样,而每个单独看都
说得通。字体把字形变成**字符**——它没有自己的几何,也就没有可以漂移的地方。
这条约束还有一层:一份产物两个宿主(H5 与 Android WebView)。字体是 bundle 里的
一个相对路径文件,`file://` 下能加载;组件化的 SVG 集合则意味着每个宿主都要
正确解析组件,而这一步在 WebView 里是额外的风险面。
### 1.2 字形名是这个项目自己的,而且不许借别人的
图标的名字是**界面层读的那套英语词汇**,取自这个代码库和整个 Web 本来就说的那些词:
`menu`、`search`、`close`、`settings`、`library`、`shelf`。没有一个是「为了不一样
而不一样」编出来的——菜单就叫 `menu` 而不是 `hamburger-glyph`,写过前端的人看见
`arrow-left` 就知道它画什么。
**曾经借过 Font Awesome 6 的类名,现在改回来了**,理由值得留着,因为它正好是
「借名字」这个想法反着咬人的地方:
| 想法 | 实际发生的事 |
| --- | --- |
| 借一套开源图标的名字,「放大镜」在全项目就只有一种拼法 | 借来的名字同时是**对别人画的图形的声明**。`magnifying-glass` 是 Font Awesome 的某个字形,熟悉那套图标的人打开 `paths.mjs`,期待看到那一个,看到的是我们的——每一个借来的名字都是这样 |
| 名字只是标签,几何是自己的 | 名字一旦是标签,就不该是别人家的标签。几何从来不是 Font Awesome 的,名字也不该是(见 §1.2 末段) |
所以现在有两条硬约束,`test/icons.test.ts` 都钉住了:
1. **没有借来的名字。** 一张被否掉的名单(`bars`、`magnifying-glass`、`xmark`、
`gear`、`table-columns`……)在测试里,改回去会失败。
2. **名字就是它画的东西**,而且整份词汇表在测试里逐字列出:加一个图标是一次要
想清楚的动作,而不是一个恰好编译得过的字符串。
命名按**用途**而非形状:`sliders`(阅读设置)而不是 `knobs`;按**读者看见的东西**
而非实现:`settings` 而不是 `gear-teeth-12`。
### 1.3 图标集是生成的
`web/tools/icons/` 下的脚本把**描边路径**编译成字体:
```
paths.mjs 41 条路径,都画在 24 单位网格上,线宽 3
↓ (这是唯一由人编辑的文件)
path.mjs 路径 → 折线
stroke.mjs 折线 → 圆头矩形(每条线段一个)
union.mjs 精确布尔并(Clipper)
build.mjs → src/styles/reader-icons.ttf + src/ui/icon-names.ts
```
重新生成:`npm run icons`。
为什么是「描边 + 布尔并」而不是直接写轮廓:字形要的是**填充区域**,而图标是
**线**。两条相接的线段如果只把各自的轮廓放进去,重叠处会有一条发丝缝——在
16px 下那条缝就是整个图标。所以必须做并集,而且必须是精确的并集。
这一步踩过的坑值得记下来,因为它决定了实现方式:先用「栅格化 + 边界追踪」
做过一版,失败了。原因是所有笔画的宽度都只有一个栅格单元,而**逐单元的边界
规则无法表达「边界在一格宽的图形上左右交替」**——结果是一个圆被追踪成 58 段
碎片。精确布尔没有这种情形。`union.mjs` 的注释里写了这段。
### 1.4 垂直度量:为什么图标能对齐
图标「显示正常但对不齐」曾经是个查不出来的问题,因为坏的地方在字体的
**垂直度量**里,而当时所有测试都只读 `cmap` 和字形名。记下来,因为只要再动
一次生成器就会重新踩到。
**约定(生成器与样式表之间的契约):**
- 网格中点(12)就是 `y = 0`,即 em 的中心线;绘制代码用 `(GRID / 2 - y)`。
- `hhea` 与 `OS/2` 的上升部/下降部各为半个 em(512 / -512),也就是
**对称**。对称不是审美:UI 里每个容器都在「居中一个盒子」,只有度量对称时
「居中盒子」和「居中字形」才是同一件事。度量不对称时,两者相差正好是那个
不对称量——这就是当初每一行图标歪的幅度都不一样的原因。
- 每个字形的墨迹必须落在 `±0.5em` 内。生成器会检查,越界直接报错。
- `OS/2` 的 Windows 度量是**无符号**的,且必须包住所有字形;`fsSelection`
打开 `USE_TYPO_METRICS`(否则引擎会去用 Windows 那一对)。
**当初错在哪:** 表格声明上升部 960、下降部 -64(基线靠近 em 底部),而字形是
围绕 em 中心画的。于是每个字形的墨迹都画在它所在盒子**上边界之上**约 0.75em,
而所有布局居中的都是盒子——`menu`、`close`、`folder` 实测偏移一致是 0.496em。
**样式表那一半:**
```css
.icon {
line-height: 1; /* 用字体自己的行盒,而不是自建一个方块 */
display: inline-block;
width: 1em; /* 唯一需要显式给的东西:可预测的前进宽度 */
text-align: center;
vertical-align: middle; /* 非 flex/grid 的行(正文里的行内图标) */
}
```
`line-height: 0` + `height: 1em` 是**错的**:那把盒子锚在基线上、向下一整个
em,而墨迹画在基线周围,于是墨迹贴在盒子上沿、盒子下方全空。居中盒子的容器
把盒子居中了,字形却差了半个 em。`transform` 也不行——它在绘制期移动字形,让
图标占的位置和看起来占的位置不再一致。
代价:`web/test/icons.test.ts` 会读字体的 `hhea` / `OS/2` / `head` 与每个字形的
包围盒来断言上面这些,也会读 `reader.css` 里的 `.icon` 规则。两边都是之前没被
任何断言覆盖的地方。
**但度量对了 ≠ 一定会对齐。第三半:盒子对了,行盒不一定在对的地方。**
#40 报的「书架页面的搜索按钮没有垂直对齐」就是这么来的,而它逃过了所有度量断言,
因为度量这次是对的。
```css
/* 旧写法:盒子居中了,墨迹没有 */
.search-glyph { position: absolute; inset-block: 0; margin-block: auto; }
```
`position: absolute` 会把 `display` 变成 `block`。规则里的盒子确实被居中了
(实测 75.98..120.19 落在一个 75.98..120.19 的输入框里,正中),但**块级盒不会
把行盒在垂直方向居中,它把行盒放在顶部**。行盒只有 1em(`line-height: 1`),
于是 1.05rem 的墨迹中线落在 y=84,而输入框中线是 y=98:高了 14px。
修法是用布局系统说这件事,而不是用 transform:
```css
.search-glyph { position: absolute; inset-block: 0; margin-block: auto;
display: grid; place-content: center; }
```
`place-content: center` 居中**行盒**,配合 §1.4 的对称度量就是居中墨迹——
和 `.icon-button` 同一套写法,所以「盒子里的字形」在这个样式表里只有一个答案。
`top: 50%; transform: translateY(-50%)` 也能修好,但更差:要在每个调用点重复、
漏掉一个就再犯一次,而且它把偏移变成 transform 的事实,输入框高度一变就要跟着改。
**量的是墨迹,不是盒子。** `run.mjs` 用 `Range` 取字形的墨迹中线断言它落在
输入框中线 ±2px 内(实测偏差 0.4px)。断言盒子是没用的:盒子在缺陷存在时**就是对的**,
这正是它活过一轮布局评审的原因。
### 1.5 字符编码
- 码位从 `U+E900` 起,连续分配,位于**私有使用区**。
- 顺序与 `paths.mjs` 中定义的顺序一致。
- `src/ui/icon-names.ts` 由生成器写出,**不要手改**:码位与字体 `cmap` 不一致
时渲染成豆腐块,而类型系统查不出这件事。
- `web/test/icons.test.ts` 直接解析字体的 `cmap` 来核对这张表,并断言字体
**不映射私有使用区之外**的任何码位(否则普通文字在应用了这个字族的元素里
会变成图标)。
### 1.6 无障碍
`Icon` 组件的 `label` 决定语义:
| `label` | 行为 |
| --- | --- |
| 字符串 | `role="img"` + `aria-label`,可访问名就是这个字符串 |
| 省略 / `null` | `aria-hidden="true"`,纯装饰 |
`IconButton` 的 `label` 是**必填**的,图标本身始终 `aria-hidden`。
一个没有文字的图标按钮,如果没有名字,屏幕阅读器就只能读出「按钮」——
而它旁边可能有五个同样的按钮。
**这是本次改动之前真实存在的问题**:原来的 `IconButton` 接收任意 `children`,
调用方传的是 Unicode 字符(`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁`)。这带来三个后果:
1. 八个字形来自八个不同的字族,光学重量各不相同,工具栏看起来是拼的;
2. `🗂` `📁` `📄` `⏮` 这些是 emoji,在部分平台被渲染成彩色的——一个彩色 emoji
出现在单色工具栏里,是「这个应用没有设计过」最直接的信号;
3. 不同平台的字体回退不同,同一个界面在 Android 和桌面浏览器上长得不一样。
现在 `IconButton` 的 `icon` 是 `IconName`,即字形的**名字**,
传一个不存在的名字是编译错误。
### 1.7 现在有哪些图标
见 `web/tools/icons/paths.mjs`,41 个字形。名字是这个项目自己的英语词汇(§1.2),
而且是**全集的、逐字列出的**——`test/icons.test.ts` 里 `VOCABULARY` 那张表就是
允许出现的全部名字,所以「这个语义用哪个字形」在改动时不会漂到另一个形状上,
也不会悄悄多出一个没人审过的形状。
---
图标统一使用 `lucide-preact` 的 SVG,`src/ui/icon.tsx` 集中维护名称映射,线宽 1.8,尺寸 1em。
界面使用 Icon / IconButton / IconTextButton,不直接嵌入字体码位或 emoji。
装饰图标标记 aria-hidden;没有文字的按钮必须有中文 aria-label。原字体文件与生成工具仅供历史兼容,不再加载到阅读界面。
## 2. 设计令牌
@@ -720,118 +538,21 @@ TXT 是唯一一种**没有作者排版**的书,所以它也是唯一一处「
§3.9 的 TXT 排版走的是另一条路(`.txt-body`,只挂在纯文本上);这一节管的是
**所有格式**的章节内容,包括 EPUB 里作者自己写的标记。
### 3.10 沉浸式阅读
### 3.10 手机阅读
**顶栏和底栏是一个状态,不是一个状态乘以二。** 读者要收起导航就是要看到正文,
上面收了下面没收,就是读者报的「只收起了一半」——底部那条一直压着每一页的最后
两行。所以两条栏由 `.reader-screen` 上的一个 `data-chrome` 属性一起开关,写法上
不可能只藏一半,动效、时长、缓动也完全一致(`--motion-duration` + 同一缓动)。
- 顶部:返回、书名、当前章节。
- 底部:上一章、本章页数滑杆、下一章;下一行是目录、朗读、界面、设置。
- 中间滑杆始终控制本章页数,不能变成全书进度。固定页章节只有一页时禁用滑杆。
- 顶部左/右、底部左/右阅读信息可在“界面”分别选择书名、章节名、章节进度(章内页码与全书百分比)、时间或不显示。默认顶部左侧章节名,底部左侧章节进度,底部右侧时间。配置按设备保存,工具栏隐藏后仍显示;长文本省略。关闭信息仍保留固定边界,避免改变正文分页。日夜圆形快捷按钮随工具栏一起显隐。
- 正文宿主的上下边界不依赖工具栏状态。工具栏覆盖正文,显隐不得改变页码、滚动位置、正文尺寸。
- 四个面板使用统一底部半屏布局;界面放排版与主题,设置放翻页行为,朗读放语音设置和播放控制。
- TXT 默认字号为基准的 1.125 倍、行距 1.8,支持两字缩进与段间距;EPUB 默认保留作者排版。
- 白、米黄、浅绿、夜间四个主题。漫画隐藏文字排版选项。
三条实现约束,改这块之前先知道:
### 3.11 朗读
- **两条栏是浮层,不是占位。** `position: absolute` 贴在屏幕上下缘,因此页面
在两种状态下都是整屏,**正文一个字都不会动**。这是读者报的「工具栏显示隐藏时,
内容会上下滑动」:当时两条栏在正常流里,stage 是 `flex: 1 1 auto`,于是每次
开关工具栏页面就重新排版、正在读的那一行上下滑 53px。工具栏是浮层,和正文不在
同一个流里——读者的判断是对的。
- **页面铺满整屏,不为浮层预留任何高度。** `.stage-host` 是 `inset: 0`,
**没有** `padding-block`。这是读者报的「内容要铺满,不要预留 stage」:版心
曾经按两条栏的高度留固定边距,于是页面在**两种状态下**都永久比手机矮 9rem
——读者收起了工具栏、要的就是整屏,顶上顶下却还是两块空白纸。
留边距的理由(浮层会盖住正文)本身没错,所以浮层的解法是浮层自己的:两条栏
用半透明底加 `backdrop-filter`(`--reader-surface-float`),读者能**透过**
工具栏看到它盖住的那一行。`.book-flow` 自己的 `padding-block` 是**页面**
边距,上下对称、与工具栏无关,一直在。
- **两条栏的高度各自只声明一次**(`.reader-screen` 上的 `--reader-chrome-top` /
`--reader-chrome-bottom`):栏本身、以及底栏上方那条朗读条(`.tts-bar` 的
`bottom`)读同一个数,不会各说各话。
- **`visibility` 在动画结束后才生效**(`transition: visibility 0s linear
var(--motion-duration)`),过渡期间由 `pointer-events: none` 兜住:滑出的过程
中标题还在,动画走完它就既看不见也点不到了。少写这一行,读者能点到一个看不见的
返回按钮。
- **右侧的快捷按钮列和两条栏一起出现、一起收起**(`.reader-rail`),由同一个
`data-chrome` 属性开关,和顶栏底栏共用一套动效。按钮住在正文的版心旁边
(宽屏时用 `--reader-measure` 算出的天头地脚里),不压字。它**不属于沉浸态**:
这些动作和顶栏是同一批目的地,属于「读者要控件」这个状态;沉浸态多出一列浮在
正文右边缘的按钮,就不是读者要的「只剩正文」。
- **快捷列只留页面内就能完成的调整**:夜间/日间(主题)、字号、听书。
这条是读者报的「竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮」定下来的:
被去掉的五个都是**目的地**而不是**调整**——目录、设置各自打开半屏面板,字体/行距/
页边距是值的一档而其完整范围住在设置面板里——所以这一列同时也装着它们的时候,
它就是一列浮在正文上的、更差的设置面板。留下的是读者**不想打开任何东西**就会去动的
那几个(尤其听书:他伸手的时候正要离开屏幕)。
每个按钮仍然是该项设置的一档,值和设置面板里的行取自**同一张阶梯**
(`FONT_SCALE_LADDER`、`THEME_LADDER`),所以两处不会各自漂移出一个对方到不了的值。
列本身仍要保证整组控件在页内:`max-height` 由两条栏的高度算出来,
装不下时 `overflow-y: auto`,并且**滚动条是画出来的**(3px 细线而不是
`scrollbar-width: none`)——一个只能靠滚动找到的控件必须看起来能滚,否则
对读者就等于不存在。`overscroll-behavior: contain` 也在,免得滑到头那一下把
手势交给下面的正文。
- **收起时正文一角仍留两个读数**(`.reading-indicator`):当前章节名在左上、
页码与总进度在左下。这是「沉浸式」与「应用停止绘制」的区别——读者收起工具栏
就是要看正文,但**仍然要知道自己在哪一页**,让他为此再点一下正是收起状态要
省掉的那次交互。两个读数 `pointer-events: none`(它们是读数不是控件,点它们
必须落到下面的翻页区),且只在收起时绘制,不和工具栏重复。
顶栏与底栏各自的形态(对应参照稿):
- **顶栏**是一排「图标在上、文字在下」的等宽按钮(`.top-button`),不是「标题 +
返回箭头」。参照稿里它是读者**扫**着用的一行工具栏,每个控件一样重、一样一步
可达。书的名字曾经写在这里,现在它属于 `.reading-indicator`——读者找标题时
眼睛去的地方是正文角上,不是工具栏。按钮只保留这个屏幕**真能做到**的动作
(返回 / 目录 / 设置):参照稿的「首页/书架/书源」里有阅读页内部走不到的目的地,
一个点了没处的按钮比少一个按钮更糟。
- **底栏**是两行(`.progress-row` + `.chapter-nav`):一行可拖动的**章节内页数**
滑杆加页码读数,一行「上一章 / 阅读进度 / 下一章」。滑杆取代了原来那条 3px 的
进度条——条只能**报告**位置,读者想到一章末尾得连点四十次「下一页」;
`input[type=range]` 一个手势就到,而且拇指、轨道、拖动惯性和无障碍(方向键、
`aria-valuenow`)都是原生的,不用自己实现一遍。
- **滑杆量的是章节内的页**(`min=1`、`max=chapterPages`),不是全书百分比。
滑杆旁边写的是「第 3/9 页」,两者必须来自同一个测量,否则一次拖动会落在
读数从没提过的位置上——这正是最初「滑杆拖到中间却跳到第 40 章」的成因。
全书进度是下面那行「阅读进度:xx%」的事,那行**只报告**,不承载手势。
所以拖动滑杆**不会跨章**:它只移动当前章节内的位置(见
`ReaderView.seekPageInChapter`)。
手势上,**三个区各管一件事,互不重叠**:左右三分之一**只翻页**,中间三分之一
**只切换工具栏显隐**。
这一条改过两轮,第二轮的起因是读者报的「在点击左右两边时只需要翻页、不需要显示
工具栏,只有在中间点击时才需要切换工具栏显隐」。第一轮的行为是:收起状态下左右
三分之一翻页**并且**把工具栏叫回来(理由是没有别的出路)。理由成立,结果和手势
打架——读者只是想往后翻,得到的却是新的一页**外加**一条压在刚读到的那行上的工具栏,
于是沉浸态根本待不住,每翻一页都被撤销一次;而真正想要控件的读者有一个专门的、
一眼可见的表达方式:点中间。
所以现在两个手势是**互斥**的,各有一个含义,这正是原来缺的性质:
左/右 = 翻页,中 = 显隐工具栏。翻页不会带出任何控件,要控件的点击也不会翻页。
### 3.11 朗读条
朗读条(`.tts-bar`)**和两条栏一样是浮层**,贴在底栏正上方(`bottom:
var(--reader-chrome-bottom)`),z-index 用同一个 `--reader-layer-bar`;但它
**不跟工具栏一起收起**——读者是边听边走的人,收起工具栏是为了看正文,不是为了
控制不了播放。
这条是读者报的「TTS 阅读体验需要优化,没法停止」逼出来的两条实现约束:
- **它必须画在正文之上,才算存在。** 它原本是 `.reader-screen` 这个 flex 列的
普通子元素,而第一个子元素 `.stage-host` 是 `inset: 0` 的绝对定位、正文又画在
`--reader-layer-page`。静态兄弟排在浮层**下面**:条画出来了、按钮能聚焦、
`aria-label` 也对,**没有任何一个能被点到**。所以它显式声明 `position: absolute`
加 z-index——一个实心按钮点不到,读者只有一个说法:「没法停止」。
- **控件自己一行,正文和滑杆另起一行。** 原本七个东西挤在一行里,句子拿
`flex: 1`,于是它成了唯一会被压缩的东西,而最后一个(停止)在 360px 上被推出
右边缘裁掉。现在上行是 `flex: 1 1 0` 的五个等宽档(上一句 / 播放暂停 / 下一句 /
句数 chip / 停止),下行是句子加滑杆:控件的宽度是等分出来的,屏幕多宽都放得下。
- **停止是方块不是 ✕。** ✕ 读起来是「关掉这条」,而那正是暂停不做的事——想要
声音停下的读者按了它,条消失了,书还在念。停止单独用主色底(`.tts-stop`)标出,
它是一行五个控件里唯一一个破坏性的。
播放器元素本身(`<audio>`)**永远不进文档**,并且显式带 `display: none` 与
`aria-hidden`:媒体元素没有读者想要的控件(条上都有),而一个会渲染它的 WebView
只会在书上画一个黑色播放器方块。这是读者报的「audio 需要隐藏」。
---
朗读控制集中在朗读面板。上一句、播放/暂停、下一句、停止均有独立按钮;关闭面板不停止播放,再从底部“朗读”进入可暂停或停止。
“从当前位置朗读”使用当前可见正文或选区作为起点。不可用的语音设置根据引擎能力隐藏。
## 4. 文案
+11 -2
View File
@@ -9,7 +9,8 @@
"version": "0.1.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"jszip": "^3.10.1"
"jszip": "^3.10.1",
"lucide-preact": "^1.47.0"
},
"devDependencies": {
"@types/jsdom": "^30.0.0",
@@ -1553,6 +1554,15 @@
"node": "20 || >=22"
}
},
"node_modules/lucide-preact": {
"version": "1.47.0",
"resolved": "https://registry.npmmirror.com/lucide-preact/-/lucide-preact-1.47.0.tgz",
"integrity": "sha512-ihver2SariDZA1n/JESxDEe5uhQ+K8YL6wiozAOLreJxoQn+aEeEpJ9rO03+I76crhS2YefiE6sM8dUmgJh7/w==",
"license": "ISC",
"peerDependencies": {
"preact": "^10.27.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1685,7 +1695,6 @@
"version": "10.29.8",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
+2 -1
View File
@@ -16,7 +16,8 @@
"ui:review:only": "node tools/ui-review/run.mjs"
},
"dependencies": {
"jszip": "^3.10.1"
"jszip": "^3.10.1",
"lucide-preact": "^1.47.0"
},
"devDependencies": {
"@types/jsdom": "^30.0.0",
+1 -1
View File
@@ -64,7 +64,7 @@ export async function loadBook(ctx: LoadContext, options: LoadOptions = {}): Pro
return { doc: await loadComicDirectory(options.comicPages, resolve), notes: [] };
}
case 'pdf':
return { doc: await loadPdf(), notes: [] };
return { doc: await loadPdf(ctx.bytes), notes: [] };
default:
throw new Error(`不支持的格式:${ctx.fileName || '未知文件'}`);
}
+2 -1
View File
@@ -18,7 +18,7 @@ import type { BookDoc, RenderMode } from './types.ts';
* book was scanned (`book.pageCount`). When it is missing, the UI degrades to
* percentage-only progress rather than showing a fake page number.
*/
export async function loadPdf(): Promise<BookDoc> {
export async function loadPdf(bytes: Uint8Array = new Uint8Array()): Promise<BookDoc> {
return {
format: 'pdf',
layout: 'fixed',
@@ -31,6 +31,7 @@ export async function loadPdf(): Promise<BookDoc> {
id: 'pdf',
label: 'PDF',
html: '',
document: { mediaType: 'application/pdf', bytes },
depth: 0,
},
],
+2
View File
@@ -30,6 +30,8 @@ export interface Section {
html?: string;
/** Binary payload, for fixed-layout formats (comic page, PDF page raster). */
image?: { mediaType: string; bytes: Uint8Array };
/** A complete fixed-layout document rendered by the browser or native host. */
document?: { mediaType: string; bytes: Uint8Array };
/** Blob URL or data URL for the image, resolved lazily by the renderer. */
imageUrl?: string;
/** Depth in the navigation tree, 0 for top level. */
+28
View File
@@ -29,7 +29,26 @@ export type ShelfDensity = 'compact' | 'cozy' | 'comfortable';
*/
export type ShelfSort = 'recent' | 'added' | 'title' | 'author';
export type ReadoutMode = 'none' | 'book' | 'chapter' | 'progress' | 'time';
export const READOUT_FIELDS = [
{ key: 'readoutTopLeft', label: '顶部左侧' },
{ key: 'readoutTopRight', label: '顶部右侧' },
{ key: 'readoutBottomLeft', label: '底部左侧' },
{ key: 'readoutBottomRight', label: '底部右侧' },
] as const;
export const READOUT_OPTIONS: Array<{ value: ReadoutMode; label: string }> = [
{ value: 'none', label: '不显示' },
{ value: 'book', label: '书名' },
{ value: 'chapter', label: '章节名' },
{ value: 'progress', label: '章节进度' },
{ value: 'time', label: '时间' },
];
export interface AppSettings extends ViewSettings {
readoutTopLeft: ReadoutMode;
readoutTopRight: ReadoutMode;
readoutBottomLeft: ReadoutMode;
readoutBottomRight: ReadoutMode;
/** UTF-8 by default; a reader can force another for an odd TXT file. */
txtEncoding: string;
/** Only meaningful for fixed-layout books. */
@@ -72,6 +91,10 @@ export interface AppSettings extends ViewSettings {
}
export const DEFAULT_APP_SETTINGS: AppSettings = {
readoutTopLeft: 'chapter',
readoutTopRight: 'none',
readoutBottomLeft: 'progress',
readoutBottomRight: 'time',
mode: 'scroll',
fontScale: 1,
lineHeight: 'inherit',
@@ -113,6 +136,11 @@ export class SettingsStore {
try {
const parsed = JSON.parse(raw) as Partial<AppSettings> & { shelfSort?: string };
this.settings = { ...DEFAULT_APP_SETTINGS, ...parsed };
for (const { key } of READOUT_FIELDS) {
if (!READOUT_OPTIONS.some(option => option.value === this.settings[key])) {
this.settings[key] = DEFAULT_APP_SETTINGS[key];
}
}
/*
* Two stored values are folded forward, and both are the same repair.
*
+98 -285
View File
@@ -28,27 +28,6 @@
* Host shell: the viewer, not the book. Safe to style freely.
* ------------------------------------------------------------------ */
/*
* The icon font.
*
* Declared here rather than in a separate sheet so the face and the token that
* sizes it are in one place. `format('truetype')` and a relative URL because the
* bundle is loaded from `file://` inside the Android WebView, where an absolute path
* resolves to the app's assets root rather than to the bundle.
*
* `font-display: block` is deliberate: icons are UI, not text. With `swap` a
* browser paints the fallback glyph — a literal private-use code point box — for one
* frame, and a row of tofu boxes flashing on every cold start reads as a broken app.
* The font is 16KB inside the bundle, so there is no network round trip to hide.
*/
@font-face {
font-family: 'Reader Icons';
src: url('./reader-icons.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: block;
}
:root {
--reader-ink: #1a1a1a;
--reader-paper: #fbfaf7;
@@ -207,7 +186,7 @@
--reader-layer-bar: 10; /* the two floating bars, over the page */
--reader-layer-rail: 14; /* the quick-action column */
--reader-layer-panel: 20; /* sheets and their scrim */
--reader-layer-readout: 30; /* the immersion readouts, above the page they describe */
--reader-layer-readout: 5; /* above the page, below bars and sheets */
/*
* The reading indicator's own inset.
@@ -646,270 +625,8 @@ book-content {
white-space: nowrap;
}
/*
* The reading indicator.
*
* Two quiet readouts pinned to the corners of the stage, drawn only while the
* chrome is hidden. `position: absolute` inside `.reader-screen` (which is
* `position: relative`) so they can sit at the top and bottom of the text without
* being part of the flow — they must not take height from *the page*, or showing
* them would re-measure the columns and move the text.
*
* `pointer-events: none` because they are readouts, not controls: a tap on the
* chapter name has to reach the tap zone underneath it and turn the page (or
* bring the chrome back), not be swallowed by a label.
*
* What the two readouts must therefore do is be drawn *over* the page, and they
* were not — the chapter name and the page count were painted over by the chapter's
* own text.
*
* The cause is worth stating exactly, because the number was not the problem. A
* `z-index` orders boxes *within one stacking context*; these two are siblings of
* the reading surface inside the screen's, so with neither declaring a `z-index`
* they were painted in **document order** — and `.book-host` comes later in the
* tree than the indicator, because the reading surface is appended before the
* chrome is rendered. It is `position: absolute`, so it is a positioned box like
* them, and the `z-index: 12` the indicator carried did nothing across two
* unrelated positioned siblings. One declared number on each side is what makes
* the order a fact of this stylesheet (see the layer scale with the other tokens).
*
* ## Why each readout is a full-width row
*
* The two readouts used to be small paper pills the width of their own text, floated
* a little inside the top and bottom edges. That is the report: "reading-indicator
* 应该是紧贴顶部/底部,并且占用一整行,现在看起来很奇怪,不整洁". A pill as wide as
* its own words is a *sticker* — it lands wherever the words end, so the chapter name
* and the page count have two different right edges, and it reads as a fragment left
* on the page rather than as a readout the screen drew. Over the chapter's own
* heading it looked worse still: one line of body text sitting under a narrower strip
* of the same colour, which is the "很奇怪" in the screenshot.
*
* So a readout is a *row*: the paper runs the full width of the stage, flush
* against the top and bottom edges, with the words inset to the page margin inside
* it. The row is what makes the two readouts one band each rather than two floating
* labels, and the flush edge is what stops them from looking like they were dropped
* slightly inside the screen.
*
* No safe-area padding is added to the *row*: the paper reaches the very edge of the
* glass, and the safe insets move the *text* inward, so the colour still runs under
* the notch and the cutout rather than leaving a strip of page showing beside it.
* That is why the safe values appear on the labels below and not here.
*
* ## Why the rows are opaque, and why that is not a paint-order question
*
* Two rounds of reports are behind this block, and they are one defect seen from
* two sides:
*
* 1. the readouts were painted *under* the chapter's own text (the `z-index`
* paragraph above), and then
* 2. *after* that was fixed — "reading-indicator 是浮动的,导致翻页后,遮挡了一部分文字".
*
* The second is the important one, because it says what the first fix did **not**
* answer. The number a readout carries at the top and bottom of the screen is
* 30px and 27px (measured at the phone size this product is used at), and a line
* of 16px/1.2 body text is 19px tall: the sentence those two rows sat on was cut
* **through the middle of its glyphs** in scroll mode, not merely overlapped. Making
* the readouts win the paint order only decides *who* is legible, and in immersion
* the page does not move to make room — so there is no version of "draw the row over
* the line" in which the reader can read both.
*
* The correction is therefore not a `z-index` at all: the row is opaque paper and it
* is an **isolated band**, so the text it discloses never lies underneath it. The
* page is inset by exactly the height of the two rows while the chrome is hidden
* (see the `[data-chrome='hidden']` rules below), which is the one thing "the
* indicator is not floating" can mean — the rows own the top and bottom of the
* screen rather than borrowing them from the first and last line.
*
* That inset is applied to the *book host* rather than to the flow inside the shadow
* root, and both halves of that matter:
*
* - **the host**, because that is the box the paginator and the scroll position are
* measured against (see `.book-host`), and the reader's restored position is a
* percentage of it. The spacer method — real blocks inside the flow — was the
* alternative, and it is the one that cannot be used: a chapter *adds its own*
* height when the reader scrolls to the end (a short chapter last on screen, the
* next chapter appended), so the position the reader had before a turn is no
* longer the position they land on afterwards. An inset does not change the
* height of the content, so the chapter a reader left is the chapter they return
* to. This is what "翻页后" in the report is about;
* - **the box**, not the flow, because the reading column's own rules live in the
* shadow root (`ui/shadow.ts`) and its padding is the reader's page margin. The
* host is the frame around that column: insetting it takes nothing from the
* column's geometry, so the text is neither re-measured nor re-typeset — it is
* the viewport that shrinks.
*
* The two facts hold together because the inset depends on the height of the rows,
* and the rows are pinned *inside* the same box: whichever of the three supported
* sizes the text lands on, the rows take their own height off the top and bottom and
* the page keeps the whole middle.
*/
.reading-indicator {
position: absolute;
inset: 0;
z-index: var(--reader-layer-readout);
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
font-size: var(--text-meta);
color: var(--reader-muted);
/* A slow fade of its own, offset by the chrome's duration: the indicator comes
up as the bars go down, so the reader never sees an empty screen. */
animation: indicator-in var(--motion-duration) var(--motion-ease);
}
/*
* The page makes room for the two rows, and only while they exist.
*
* The inset is on the *book host* rather than on the flow inside the shadow root,
* for the two reasons the block above sets out: the host is what the paginator and
* the restored scroll position are measured against, and the flow's own padding is
* the reader's page margin. Nothing about the column's geometry changes here — the
* frame around it shortens — so no chapter is re-typeset and no offset is
* invalidated by hiding or showing the chrome.
*
* `--reader-readout-top` / `--reader-readout-bottom` carry the heights, because the
* same two numbers have to be the inset *and* the row. A row's height is its content,
* and the tokens below are that content written once: one line at the readout's own
* font size, the breathing room above and below it, and the hairline the rule adds —
* because `box-sizing: border-box` does not shrink a box whose height is its content,
* so an inset that forgot the border would be 1px short and clip the last line by
* exactly that much. Derived here rather than measured in the page, because an inset
* that reads the row's own height back is a feedback loop: the row's height is inside
* the box the inset shortens.
*
* The bottom row is the shorter of the two: the chapter name carries the safe-area
* inset on its outer edge and the page count does not, so the two are not the same
* number and must not be given one. Both are `line-height: 1` in the row rules below
* — a normal line box is ~1.2em, a height these tokens cannot see.
*/
:root {
--reader-readout-top: calc(var(--text-label) + 0.35rem * 2 + var(--safe-top) + 1px);
--reader-readout-bottom: calc(var(--text-meta) + 0.35rem * 2 + var(--safe-bottom) + 1px);
}
/*
* `line-height: 1` on both rows is load-bearing rather than tidy: the page's inset is
* computed from these two heights (see the tokens above), and a normal line box is
* ~1.2em — a height the token cannot see, so the inset would be 20% short of the row
* and the last line of the page would be cut by exactly that much. One line, one
* known height, and what the token says is what the row is.
*/
.indicator-chapter,
.indicator-progress {
display: block;
width: 100%;
background: var(--reader-paper);
line-height: 1;
padding-block: 0.35rem;
/* The text is inset to the page margin and the safe area; the paper is not. */
padding-inline: calc(var(--reader-indicator-inset) + var(--safe-left))
calc(var(--reader-indicator-inset) + var(--safe-right));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.indicator-chapter {
/* The chapter name is the longer of the two, so it is the one that can be
ellipsised when a title runs past the row. */
font-size: var(--text-label);
padding-block-start: calc(0.35rem + var(--safe-top));
/* A hairline between the readout and the page, on the inner edge only.
The paper behind a readout is the page's own colour, so without a line the row
is invisible on a plain chapter — and the chapter name at the top then reads as
a stray duplicate of the heading underneath it, which is the "很奇怪" in the
report. One rule in the shared line colour turns it into a band the screen drew.
The outer edge has no line: it is the edge of the screen. */
border-block-end: 1px solid var(--reader-line);
}
.indicator-progress {
font-variant-numeric: tabular-nums;
color: var(--reader-ink);
padding-block-end: calc(0.35rem + var(--safe-bottom));
border-block-start: 1px solid var(--reader-line);
}
/*
* The page is a whole number of lines, and the surface the reader looks through is
* exactly that tall.
*
* `--reader-page-trim` is the remainder of the viewport divided by the line height,
* published by the view (`syncPageTrim`) because only the rendered text knows the grid
* the book is set on: `line-height` is `inherit` by default, so the reader's own
* stylesheet has no number to divide by. Floored away, the page holds a whole number of
* lines and its last line ends at the glass; left in, the strip at the foot of the page
* shows the first line of the *next* page with its descenders sliced off.
*
* Carried on `inset-block-end`, and the three reasons are worth stating because each
* one is a way this can be got wrong:
*
* - **an inset, not a `height`.** The host is absolutely positioned, so its top and
* bottom are already two statements about where the box is; a height would be a
* third, trying to agree with both. One inset says one more thing — where the box
* ends — and composes with the readout rows instead of replacing them;
* - **not padding.** The host is `border-box` (the document-wide rule), so a
* padding-bottom shortens the *content* box and leaves `clientHeight` — the number
* the page arithmetic and the offsets are built on — at its old value. The page
* would then be measured one height and drawn another, which is the disagreement
* this removes rather than a second version of it;
* - **off the bottom, not the top.** The top edge is the line the reader is meant to
* start on; trimming it would push the first line under the chapter name's row,
* which is the same defect at the other end of the same page.
*
* The write is the view's and the read is the stylesheet's, and the trim applies only
* while the readouts do: with the chrome up the two rows are not on screen, the footer
* floats over the page instead of taking height from it (see `.footer`), and there is
* nothing to make room for. Scoping the rule to the hidden state is what keeps the
* reading column at the same block inset in both states — which is the property that
* makes showing and hiding the toolbar a pure repaint rather than a re-measure.
*
* `0px` until the view has measured a chapter, which is the correct first frame: the
* trim is a correction to a page whose line grid is known, and guessing one before that
* would clip text to a number nobody computed.
*/
.reader-screen[data-chrome='hidden'] .book-host {
inset-block-start: var(--reader-readout-top);
/* The row's height and the grid's remainder, in one expression: they are independent
corrections to the same edge — one is chrome and one is the text's own metric — so
they add rather than replacing one another. */
inset-block-end: calc(var(--reader-readout-bottom) + var(--reader-page-trim, 0px));
}
/*
* The icon glyph.
*
* An icon is one character of a private-use font, so it is laid out as text — and the
* only thing that decides where a character lands is the font's own metrics. That is why
* this rule is three lines and not a geometry puzzle, and it is also what went wrong:
* the rule used to say `line-height: 0; display: inline-block; width: 1em; height: 1em`,
* which replaces the font's line box with a 1em box anchored at the baseline. The box
* then extends *downward* from the baseline while the glyph's ink is drawn *around* it,
* so the ink ended up at the box's top edge with the whole em empty beneath it. Every
* container in this stylesheet centres its children, the box was centred correctly, and
* the ink was half an em away from where the box said it was — measured at 0.496em
* across `menu`, `close` and `folder`, i.e. half the box, in every row, at every size.
*
* So the box is the font's line box (`line-height: 1`) and the font's line box is
* symmetric: `tools/icons/build.mjs` declares an ascent and descent of half the em each
* and draws every glyph inside it, so centring the line centres the ink. `width: 1em` is
* kept because it is the one thing the layout needs that the font cannot supply — a
* predictable advance width, so a row of icons and labels is a row and not a function of
* which glyphs happen to be in it.
*
* `vertical-align: middle` is for the rows that are *not* flex or grid — a glyph inline
* in a paragraph of text, which is where `baseline` would otherwise apply and put the
* icon's baseline on the text's, half an em low. In a flex or grid container (most of
* this app's chrome) it is inert and the container's own alignment does the work, which
* is the point: the icon needs no special case in either.
*
* `font-synthesis: none` matters for a private-use glyph: some engines will synthesise a
* bold or oblique from the regular face, and a synthesised icon is a smeared icon.
*/
/* Shared sizing for the SVG icon primitive. */
.icon {
font-family: 'Reader Icons', sans-serif;
font-size: 1.15rem;
font-style: normal;
font-weight: 400;
@@ -3950,3 +3667,99 @@ book-content {
.sources-search { display:flex; align-items:end; gap:.5rem; margin:1rem 0; }
.sources-search label { flex:1; margin:0; }
.source-description { max-height:6rem; overflow:auto; font-size:.9rem; white-space:pre-wrap; }
/* Mobile reading: the surface and its information strip never resize with chrome. */
[data-theme='green'] {
--reader-ink: #23392c;
--reader-paper: #d5ecd7;
--reader-surface: #e8f2e8;
--reader-muted: #617a65;
--reader-line: #bdd6bf;
--reader-accent: #497553;
}
svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; vertical-align: middle; }
.reader-screen { --reader-footer-height: calc(8.5rem + var(--safe-bottom)); }
.reader-screen .book-host {
inset-block-start: calc(1.8rem + var(--safe-top));
inset-block-end: calc(1.8rem + var(--safe-bottom) + var(--reader-page-trim, 0px));
}
.reading-indicator {
position: absolute;
inset-inline: 0;
padding-inline: max(1.25rem, var(--safe-left)) max(1.25rem, var(--safe-right));
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
color: var(--reader-muted);
background: var(--reader-paper);
font-size: 0.72rem;
line-height: 1.2;
z-index: var(--reader-layer-readout);
pointer-events: none;
}
.reading-indicator-top { top: 0; height: calc(1.8rem + var(--safe-top)); padding-top: calc(0.5rem + var(--safe-top)); }
.reading-indicator-bottom { bottom: 0; height: calc(1.8rem + var(--safe-bottom)); padding-top: 0.35rem; padding-bottom: var(--safe-bottom); }
.reading-indicator > span { min-width: 0; flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-variant-numeric: tabular-nums; }
.indicator-left { text-align: start; }
.indicator-right { text-align: end; }
.reading-indicator > span:empty { flex: 0; }
.reader-screen .topbar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
height: auto;
min-height: calc(4.5rem + var(--safe-top));
padding: calc(0.6rem + var(--safe-top)) 1rem 0.6rem;
background: var(--reader-surface);
border: 0;
box-shadow: 0 2px 12px #0000000a;
}
.reader-heading { min-width: 0; display: grid; gap: 0.3rem; }
.reader-heading strong { font-size: 1.05rem; font-weight: 600; }
.reader-heading span { font-size: 0.75rem; color: var(--reader-muted); }
.reader-heading strong, .reader-heading span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reader-screen .footer {
height: var(--reader-footer-height);
padding: 0.5rem 1rem calc(0.4rem + var(--safe-bottom));
background: var(--reader-surface);
border: 0;
box-shadow: 0 -2px 12px #0000000a;
}
.reader-screen .progress-row { display: flex; align-items: center; gap: 0.75rem; padding: 0; min-height: 3rem; }
.reader-screen .nav-chapter { flex: 0 0 auto; font-size: 0.8rem; padding: 0.35rem 0; min-height: 44px; }
.reader-scrub { flex: 1; min-width: 0; display: grid; text-align: center; }
.reader-screen .progress-scrubber { width: 100%; min-height: 28px; margin: 0; }
.reader-screen .progress-page { font-size: 0.65rem; color: var(--reader-muted); }
.reader-actions { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.25rem; }
.reader-actions .top-button { min-width: 0; max-width: none; min-height: 60px; padding: 0.45rem 0; gap: 0.45rem; }
.reader-actions .icon { font-size: 1.4rem; }
.reader-actions .top-button-label { font-size: 0.75rem; font-weight: 400; }
.reader-screen .reader-rail {
top: auto;
bottom: calc(var(--reader-footer-height) + 1rem);
left: auto;
right: max(1rem, var(--safe-right));
width: auto;
max-height: none;
padding: 0;
transform: none;
border: none;
background: transparent;
box-shadow: none;
}
.reader-screen .rail-button { width: 48px; height: 48px; border-radius: 50%; background: var(--reader-surface); box-shadow: 0 3px 12px #00000020; }
.reader-screen .rail-button .icon { font-size: 1.5rem; }
.reader-screen .panel { border-radius: 20px 20px 0 0; max-height: 62%; background: var(--reader-surface); box-shadow: 0 -6px 30px #00000018; }
.reader-screen .panel-header { padding: 0.5rem 1.25rem 0.75rem; }
.reader-screen .panel-body { padding-inline: 1.25rem; }
.reader-screen .panel .field { padding-block: 0.8rem; }
.reader-screen .panel .tts-bar { position: static; border-radius: 12px; width: auto; transform: none; margin-bottom: 1rem; }
.reader-screen .panel-header > .icon-button { margin-inline-start: auto; }
.reader-screen .panel .field { grid-template-columns: minmax(5.5rem, max-content) minmax(0, 1fr); gap: 0.75rem; padding-block: 0.5rem; }
.reader-screen .field input[type='range'] { min-height: 32px; }
.reader-screen .field input[type='range']::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
.reader-screen .field input[type='range']::-webkit-slider-thumb { appearance: none; width: 18px; height: 18px; margin-top: -7px; border-radius: 50%; background: var(--reader-accent); }
.reader-screen .field input[type='range']::-moz-range-track { height: 4px; border-radius: 2px; background: var(--reader-line); }
.reader-screen .field input[type='range']::-moz-range-thumb { width: 18px; height: 18px; border: 0; border-radius: 50%; background: var(--reader-accent); }
+31 -58
View File
@@ -1,61 +1,34 @@
/**
* The icon primitive.
*
* ## Why a font, and not inline SVG
*
* The UI needs roughly thirty glyphs, in two colours, at four sizes, in one bundle
* that has to work from `file://` in a WebView. Inline SVG would mean thirty small
* components, each carrying its own `viewBox`, `stroke-width` and `fill` — which is
* exactly how an icon set becomes visually inconsistent, because every one of those
* three is a place where an individual glyph can drift. A font makes the glyph a
* *character*: it inherits `font-size` and `color` from wherever it sits, scales
* with the layout, and cannot carry divergent stroke geometry because there is no
* per-glyph geometry in the UI layer at all.
*
* The set is generated (see `tools/icons/`) from one stroke width on one 24-unit
* grid, which is what the optical consistency actually rests on.
*
* ## Why the accessible name is required
*
* An icon button with no text is a button whose name screen readers cannot read, so
* `label` is not optional in `IconButtonProps` and it is not optional here either.
* The glyph itself is always `aria-hidden` — it is punctuation, not content — and the
* label is what carries the meaning. Where the label would repeat adjacent visible
* text the caller passes `label={null}`, which is the explicit way to say "this glyph
* is decorative", rather than omitting the attribute and leaving it ambiguous.
*/
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
import { type JSX } from './vendor/preact.ts';
import {
Menu, ChevronLeft, ChevronRight, ArrowLeft, Folder, FileText, SlidersHorizontal,
Search, X, Plus, Check, Upload, Trash2, Download, RefreshCw, BookOpen,
Library, Play, Pause, SkipBack, SkipForward, Square, Headphones, Moon, Sun,
Eye, Info, TriangleAlert, Clock, Ellipsis, Pencil, ArrowDownUp, LogOut,
CirclePlus, IndentIncrease, Type, MoveVertical, Settings, SlidersVertical,
} from 'lucide-preact';
import type { IconName } from './icon-names.ts';
import type { JSX } from './vendor/preact.ts';
export type { IconName };
export interface IconProps {
name: IconName;
/**
* Accessible name. `null` marks the glyph decorative; the surrounding control is
* then responsible for being reachable by name.
*/
label?: string | null;
/** Extra classes, for sizing or colour. */
class?: string;
}
/**
* Renders one glyph from the icon font.
*
* The element is a `<span>` rather than an `<svg>` or an `<i>`: `i` is italic text
* and inherits font-style, and `svg` would promise geometry that is not there.
*/
const ICONS = {
menu: Menu, 'chevron-left': ChevronLeft, 'chevron-right': ChevronRight,
'arrow-left': ArrowLeft, folder: Folder, 'file-text': FileText,
sliders: SlidersHorizontal, search: Search, close: X, plus: Plus, check: Check,
upload: Upload, trash: Trash2, download: Download, refresh: RefreshCw,
book: BookOpen, shelf: Library, play: Play, pause: Pause,
'step-backward': SkipBack, 'step-forward': SkipForward, stop: Square,
volume: Headphones, moon: Moon, sun: Sun, eye: Eye, info: Info,
warning: TriangleAlert, clock: Clock, more: Ellipsis, edit: Pencil,
sort: ArrowDownUp, logout: LogOut, 'sign-out': LogOut, 'add-circle': CirclePlus,
indent: IndentIncrease, 'text-size': Type, 'line-height': MoveVertical,
settings: Settings, library: Library, tune: SlidersVertical,
font: Type, 'volume-high': Headphones, 'backward-step': SkipBack,
'forward-step': SkipForward, bars: Menu, gear: Settings, xmark: X,
'magnifying-glass': Search, books: Library,
} satisfies Record<IconName, typeof Menu>;
export interface IconProps { name: IconName; label?: string | null; class?: string; }
/** Shared geometry and stroke weight, independent of font rendering. */
export function Icon({ name, label, class: className }: IconProps): JSX.Element {
return (
<span
className={className ? `icon ${className}` : 'icon'}
aria-hidden={label ? undefined : 'true'}
role={label ? 'img' : undefined}
aria-label={label ?? undefined}
>
{ICON_CODEPOINT_TABLE[name]}
</span>
);
const Glyph = ICONS[name];
return <Glyph class={className ? 'icon ' + className : 'icon'} size="1em"
strokeWidth={1.8} aria-hidden={label ? undefined : true}
role={label ? 'img' : undefined} aria-label={label ?? undefined} />;
}
+74 -322
View File
@@ -16,10 +16,12 @@
*/
import type { AppSettings } from '../store/settings.ts';
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, READOUT_OPTIONS, type ReadoutMode } from '../store/settings.ts';
import { ReaderIndicators } from './reader-indicators.tsx';
import type { SpeechEngineKind } from '../render/speech.ts';
import { type ComponentChildren, type JSX } from './vendor/preact.ts';
import { IconButton, IconTextButton, SectionTitle } from './toolkit.tsx';
import { ICON_CODEPOINT_TABLE, type IconName } from './icon-names.ts';
import { Icon, type IconName } from './icon.tsx';
/**
* A table-of-contents row.
@@ -49,6 +51,10 @@ export interface SpeechBarState {
}
export interface ChromeState {
readoutTopLeft?: ReadoutMode;
readoutTopRight?: ReadoutMode;
readoutBottomLeft?: ReadoutMode;
readoutBottomRight?: ReadoutMode;
title: string;
author: string;
chromeVisible: boolean;
@@ -59,6 +65,7 @@ export interface ChromeState {
chapterLabel: string;
tocOpen: boolean;
settingsOpen: boolean;
settingsTab?: 'appearance' | 'behavior' | 'speech';
toc: ChromeTocEntry[];
currentSectionId: string;
/** 1-based position of the current page inside its chapter, and the count. */
@@ -113,7 +120,7 @@ export interface ChromeState {
export interface ChromeHandlers {
onBack(): void;
toggleToc(): void;
toggleSettings(): void;
toggleSettings(tab?: 'appearance' | 'behavior' | 'speech'): void;
onSetting(patch: Partial<AppSettings>): void;
onSpeechSetting(patch: Partial<AppSettings>): void;
onVoice(value: string): void;
@@ -153,190 +160,49 @@ export interface ReaderChromeProps {
}
export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX.Element {
const fixed = state.layout === 'fixed';
const totalPages = Math.max(1, state.chapterPages);
const currentPage = Math.min(totalPages, Math.max(1, state.pageInChapter));
const pageLabel = '本章第 ' + currentPage + '/' + totalPages + ' 页';
const tab = state.settingsTab ?? 'behavior';
return (
<>
{/* The top bar.
—
A row of labelled buttons, not a title with a back arrow. The reference
layout is a toolbar the reader *scans*: five controls of equal weight,
each an icon over its own name, so nothing has to be learned and nothing
is announced twice. The book's title lived here before; it is still on
screen when the chrome is hidden (see `.reading-indicator`), which is
where a reader actually looks for it — a title that disappears with the
toolbar was the reason the indicator had to exist at all.
Buttons are kept to the ones this screen can really do. The reference's
首页/书架/书源/目录/设置 collapses to 返回/目录/设置 here because there is
no book-source browser *inside* the reader: a button that navigates
somewhere the screen cannot go is worse than one fewer button. */}
<div className="topbar" hidden={!state.chromeVisible}>
<TopButton icon="arrow-left" label="返回" onClick={handlers.onBack} />
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<TopButton icon="sliders" label="设置" onClick={handlers.toggleSettings} />
<IconButton label="返回" icon="arrow-left" onClick={handlers.onBack} />
<div className="reader-heading"><strong>{state.title}</strong><span>{state.chapterLabel}</span></div>
</div>
{/* Outside the flex flow: see the comment on `.status-bar`. It has to be a
sibling of the stage rather than a child of it, because the stage's slot
measures the space between the chrome and a strip that came and went would
change that measurement on every sync. */}
<div
className="status-bar"
hidden={state.statusState === 'idle' && state.statusText === ''}
data-state={state.statusState}
role="status"
aria-live="polite"
>
<span className="status-dot" aria-hidden="true" />
<span className="status-text">{state.statusText}</span>
<div className="status-bar" hidden={state.statusState === 'idle' && state.statusText === ''}
data-state={state.statusState} role="status" aria-live="polite">
<span className="status-dot" aria-hidden="true" /><span className="status-text">{state.statusText}</span>
</div>
<StageHost stage={stage} />
{/* The reading indicator.
—
What is left on screen when the chrome is hidden. The reference keeps two
quiet facts pinned to the corners — which chapter, and how far in — and
nothing else. That is the difference between "沉浸式" and "the app stopped
drawing": a reader who has hidden the toolbar still wants to know where
they are, and making them tap to find out is what the hidden state was
supposed to save them from.
Both live in the page margin at the very top and bottom of the stage, are
`pointer-events: none` (they are readouts, not controls), and are drawn
only while the chrome is hidden so they do not double up with it. */}
{!state.chromeVisible ? (
<div className="reading-indicator" aria-hidden="true">
<span className="indicator-chapter">{state.chapterLabel}</span>
<span className="indicator-progress">
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页 ` : ''}
{percentOf(state.progress)}
</span>
</div>
) : null}
{/* The quick-action rail.
—
A column of controls on the right edge, shown **and hidden with the two
bars**. That pairing is the request, and it is also the coherent reading
of what the rail is for: it is a second way to reach the two things a
reader changes *while reading* — the theme and the voice — so it belongs
to the same "the reader has asked for the controls" state as the bars.
Drawn while the chrome was hidden, it was the one piece of chrome that
survived immersion — a reader who tapped to hide the navigation still had
controls floating over the right edge of the text, which is not the "only
the page" they asked for.
It is drawn unconditionally and collapsed by `data-chrome` in CSS, exactly
like the two bars: `visibility: hidden` at the end of the fade is what
takes it out of reach of both a finger and a screen reader, and one
attribute for three bands is what makes "the chrome is hidden" a single
fact.
## What is in it, and why the list is exactly this short
—
Three controls, and the report that decided them is the whole of it:
"竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮". Those five are
destinations, not adjustments: 目录 opens a sheet, 设置 opens a sheet, and
字体/行距/边距 are *steps* of values whose full range lives in the 设置
sheet — so a reader who wanted any of the five was already going to open a
sheet, and putting them on the rail as well made it a second, worse copy of
the settings panel floating over the text.
What is left is the two that are genuinely adjusted *in place*, plus 听书:
- 主题 (light → sepia → night): a single tap that changes the whole page,
and the one control a reader in the dark reaches for without wanting to
open anything;
- 字号 (one step): the other half of the same "the text is not right"
moment, and a step up/down is faster than finding a slider;
- 听书: the one control a reader reaches for *without* leaving the page,
because they are about to look away from the screen.
Every one of them is a destination the screen can actually reach: a button
that does nothing is worse than a missing one. */}
<div
className="reader-rail"
role="toolbar"
aria-label="阅读快捷操作"
aria-orientation="vertical"
>
<RailButton
icon={state.theme === 'dark' ? 'sun' : 'moon'}
<ReaderIndicators state={state} />
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作">
<RailButton icon={state.theme === 'dark' ? 'sun' : 'moon'}
label={state.theme === 'dark' ? '日间' : '夜间'}
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
/>
<RailButton
icon="text-size"
label="字号"
onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })}
/>
<RailButton
icon={state.tts.active ? 'pause' : 'volume'}
label={state.tts.active ? '暂停朗读' : '听书'}
pressed={state.tts.active}
onClick={handlers.onSpeechToggle}
/>
onClick={() => handlers.onSetting({ theme: state.theme === 'dark' ? 'light' : 'dark' })} />
</div>
{/* The bottom bar.
—
Two rows, and the split is the reference's: a scrubber the reader can
drag to any page **of this chapter**, then a navigation row with the
chapter buttons at the ends and the reading progress in the middle. The
scrubber replaces the old 3px bar plus the "本章 x/y 页" readout — a bar
that only reports could not be used to *go* anywhere, and the reader who
wanted the end of the chapter had to tap the next button forty times.
It is scoped to the chapter, not the book: the readout beside it says
"第 3/9 页", and the reference's own screenshot shows the thumb near the
middle at page 25 of 32 — a book-wide bar would be two thirds of the way
across and blank in a chapter that is one of a thousand. The whole-book
number is still on screen, as the "阅读进度" readout below, which is the
row that *reports*; the slider is the row that *moves*. */}
<div className="footer" hidden={!state.chromeVisible}>
<div className="progress-row">
<input
className="progress-scrubber"
type="range"
min={1}
max={Math.max(1, state.chapterPages)}
step={1}
// The *page*, not the whole-book fraction. The readout beside the thumb
// names a page in this chapter, so the control has to move by the same
// unit; a book-percentage slider next to "第 3/9 页" is two answers to
// two different questions sharing one thumb.
value={Math.min(Math.max(1, state.pageInChapter), Math.max(1, state.chapterPages))}
aria-label="章节内页数"
aria-valuetext={
state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : '章节内页数'
}
disabled={state.chapterPages <= 1}
onInput={(event) =>
handlers.onScrubPage(Number((event.currentTarget as HTMLInputElement).value))
}
/>
<span className="progress-page">
{state.chapterPages > 0 ? `第 ${state.pageInChapter}/${state.chapterPages} 页` : ''}
</span>
<button type="button" className="nav-chapter" disabled={state.navigating || state.chapterIndex <= 1}
onClick={() => handlers.onChapter(-1)}>{fixed ? '上一页' : '上一章'}</button>
<div className="reader-scrub">
<input className="progress-scrubber" type="range" min={1} max={totalPages} step={1}
value={currentPage} aria-label="章节内页数"
aria-valuetext={pageLabel} disabled={totalPages <= 1 || state.navigating}
onInput={(event) => handlers.onScrubPage(Number((event.currentTarget as HTMLInputElement).value))} />
<span className="progress-page">{pageLabel}</span>
</div>
<button type="button" className="nav-chapter" disabled={state.navigating || state.chapterIndex >= state.chapterCount}
onClick={() => handlers.onChapter(1)}>{fixed ? '下一页' : '下一章'}</button>
</div>
<div className="footer-row chapter-nav">
<button
type="button"
className="nav-chapter"
disabled={state.navigating || state.chapterIndex <= 1}
onClick={() => handlers.onChapter(-1)}
>
<span className="icon" aria-hidden="true">{iconGlyph('chevron-left')}</span>
上一章
</button>
<span className="nav-progress">阅读进度:{percentOf(state.progress)}</span>
<button
type="button"
className="nav-chapter"
disabled={state.navigating || state.chapterIndex >= state.chapterCount}
onClick={() => handlers.onChapter(1)}
>
下一章
<span className="icon" aria-hidden="true">{iconGlyph('chevron-right')}</span>
</button>
<div className="reader-actions">
<TopButton icon="menu" label="目录" onClick={handlers.toggleToc} />
<TopButton icon="volume" label="朗读" onClick={() => handlers.toggleSettings('speech')} />
<TopButton icon="text-size" label="界面" onClick={() => handlers.toggleSettings('appearance')} />
<TopButton icon="settings" label="设置" onClick={() => handlers.toggleSettings('behavior')} />
</div>
</div>
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
{state.tocOpen ? (
<Panel title="目录" subtitle={`${state.toc.length} 章`} onClose={handlers.toggleToc}>
{state.canRefresh ? (
@@ -381,8 +247,11 @@ export function ReaderChrome({ state, stage, handlers }: ReaderChromeProps): JSX
) : null}
{state.settingsOpen ? (
<Panel title="阅读设置" onClose={handlers.toggleSettings}>
<SettingsBody state={state} handlers={handlers} />
<Panel title={tab === 'appearance' ? '界面' : tab === 'speech' ? '朗读' : '阅读设置'} onClose={() => handlers.toggleSettings(tab)}>
{tab === 'speech' ? <>
{state.tts.active ? <SpeechBar state={state.tts} handlers={handlers} /> : null}
<SpeechSettings state={state} handlers={handlers} />
</> : <SettingsBody state={state} handlers={handlers} />}
</Panel>
) : null}
</>
@@ -464,52 +333,15 @@ function Panel({
function SettingsBody({ state, handlers }: { state: ChromeState; handlers: ChromeHandlers }): JSX.Element {
return (
<>
{/* The three controls a reader reaches for most often are also the three
the old bottom toolbar spent most of its width on. They are a row of
large targets at the top of the sheet rather than a toolbar over the
text, because the sheet is where the reader already is when they decide
the text is too small — and a toolbar that stays on screen to serve them
is a toolbar that covers the book for everyone else. */}
<SectionTitle>快捷</SectionTitle>
<div className="quick-row">
<QuickButton
icon="text-size"
label="字号"
value={`${Math.round(state.fontScale * 100)}%`}
onClick={() => handlers.onSetting({ fontScale: stepFontScale(state.fontScale, 1) })}
/>
<QuickButton
icon="indent"
label="段落缩进"
value={state.showTxtRows ? indentLabel(state.txtIndent) : '—'}
disabled={!state.showTxtRows}
onClick={() => handlers.onSetting({ txtIndent: stepIndent(state.txtIndent) })}
/>
<QuickButton
icon="line-height"
label="段间距"
value={state.showTxtRows ? `${state.txtParagraphGap.toFixed(2)} 字` : '—'}
disabled={!state.showTxtRows}
onClick={() => handlers.onSetting({ txtParagraphGap: stepGap(state.txtParagraphGap) })}
/>
<QuickButton
icon={state.theme === 'dark' ? 'sun' : 'moon'}
label="主题"
value={THEME_LABELS[state.theme] ?? state.theme}
onClick={() => handlers.onSetting({ theme: nextTheme(state.theme) })}
/>
</div>
<SectionTitle>排版</SectionTitle>
<SegmentedRow
label="翻页方式"
options={[
{ value: 'scroll', label: '滚动' },
{ value: 'paged', label: '翻页' },
]}
value={state.mode}
onChange={(value) => handlers.onSetting({ mode: value as AppSettings['mode'] })}
/>
{(state.settingsTab ?? 'behavior') === 'appearance' ? <>
<SectionTitle>阅读信息</SectionTitle>
{READOUT_FIELDS.map(({ key, label }) => (
<SelectRow key={key} label={label} options={READOUT_OPTIONS}
value={state[key] ?? DEFAULT_APP_SETTINGS[key]}
onChange={(value) => handlers.onSetting({ [key]: value as ReadoutMode })} />
))}
<SectionTitle>正文与主题</SectionTitle>
{state.layout !== 'fixed' ? <>
<SliderRow
label="字号"
value={state.fontScale}
@@ -562,11 +394,13 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
value={state.fontFamily}
onChange={(value) => handlers.onSetting({ fontFamily: value })}
/>
</> : null}
<SegmentedRow
label="主题"
options={[
{ value: 'light', label: '白' },
{ value: 'sepia', label: '米黄' },
{ value: 'green', label: '浅绿' },
{ value: 'dark', label: '夜间' },
]}
value={state.theme}
@@ -624,6 +458,18 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
</>
) : null}
</> : <>
{state.layout !== 'fixed' ?
<SegmentedRow
label="翻页方式"
options={[
{ value: 'scroll', label: '滚动' },
{ value: 'paged', label: '翻页' },
]}
value={state.mode}
onChange={(value) => handlers.onSetting({ mode: value as AppSettings['mode'] })}
/>
: null}
<SectionTitle>翻页</SectionTitle>
<SegmentedRow
label="点击区域"
@@ -670,8 +516,7 @@ function SettingsBody({ state, handlers }: { state: ChromeState; handlers: Chrom
/>
) : null}
<SectionTitle>朗读</SectionTitle>
<SpeechSettings state={state} handlers={handlers} />
</>}
</>
);
}
@@ -747,7 +592,7 @@ function SpeechSettings({ state, handlers }: { state: ChromeState; handlers: Chr
onChange={(value) => handlers.onSpeechSetting({ ttsAutoAdvance: value === 'auto' })}
/>
<button type="button" className="button" onClick={handlers.onSpeakFromHere}>
从头朗读这一章
从当前位置朗读
</button>
</>
);
@@ -887,6 +732,8 @@ function SliderRow({
</label>
<input
type="range"
aria-label={label}
aria-valuetext={format(value)}
min={min}
max={max}
step={step}
@@ -912,7 +759,7 @@ function SelectRow({
return (
<div className="field">
<label>{label}</label>
<select value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
<select aria-label={label} value={value} onChange={(event) => onChange((event.currentTarget as HTMLSelectElement).value)}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
@@ -935,7 +782,7 @@ function SelectRow({
function TopButton({ icon, label, onClick }: { icon: IconName; label: string; onClick(): void }): JSX.Element {
return (
<button type="button" className="top-button" aria-label={label} onClick={onClick}>
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
<Icon name={icon} />
<span className="top-button-label">{label}</span>
</button>
);
@@ -969,107 +816,12 @@ function RailButton({
{...(pressed === undefined ? {} : { 'aria-pressed': pressed })}
onClick={onClick}
>
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
<Icon name={icon} />
</button>
);
}
/** The glyph for an icon name, from the generated code point table. */
function iconGlyph(name: IconName): string {
return ICON_CODEPOINT_TABLE[name];
}
/**
* A labelled quick-action tile.
*
* A tap rather than a slider for the three controls a reader nudges most: a slider
* is the right control for "any value in this range" and the wrong one for "one
* step bigger", which is what a reader actually wants when the text looks small.
* The full range is still in the rows below, so the tile is a shortcut and never
* the only way to reach a value.
*/
function QuickButton({
icon,
label,
value,
disabled,
onClick,
}: {
icon: IconName;
label: string;
value: string;
disabled?: boolean;
onClick(): void;
}): JSX.Element {
return (
<button
type="button"
className="quick-button"
disabled={disabled}
aria-label={`${label} ${value}`}
onClick={onClick}
>
<span className="icon" aria-hidden="true">{iconGlyph(icon)}</span>
<span className="quick-label">{label}</span>
<span className="quick-value">{value}</span>
</button>
);
}
/** The next font scale in the ladder, so a tap lands on a round percentage. */
const FONT_SCALE_LADDER = [0.8, 0.9, 1, 1.1, 1.25, 1.4, 1.6, 1.8, 2.0, 2.2];
function stepFontScale(current: number, direction: 1 | -1): number {
const index = FONT_SCALE_LADDER.findIndex((value) => value >= current - 0.001);
const at = index === -1 ? FONT_SCALE_LADDER.length - 1 : index;
const next = Math.min(FONT_SCALE_LADDER.length - 1, Math.max(0, at + direction));
return FONT_SCALE_LADDER[next] ?? current;
}
/** Paragraph indent, cycling 无 → 1 → 2 → 3 → 4 characters and back. */
const INDENT_LADDER = [0, 1, 2, 3, 4];
function stepIndent(current: number): number {
const index = INDENT_LADDER.findIndex((value) => value > current + 0.001);
return index === -1 ? INDENT_LADDER[0]! : INDENT_LADDER[index]!;
}
/** Paragraph spacing, cycling in quarter-character steps and back to none. */
const GAP_LADDER = [0, 0.25, 0.5, 0.75, 1, 1.25];
function stepGap(current: number): number {
const index = GAP_LADDER.findIndex((value) => value > current + 0.001);
return index === -1 ? GAP_LADDER[0]! : GAP_LADDER[index]!;
}
function indentLabel(indent: number): string {
return indent === 0 ? '无' : `${indent.toFixed(2)} 字`;
}
/**
* Line height, as the settings sheet's own ladder.
*
* The value is a *string*, because "原书" (`inherit`) is one of the choices and is
* not a number — the book's own line height is the default and the setting has to
* be able to mean "do not touch it". The sheet's `SegmentedRow` is the only reader
* of this list now that the rail no longer steps it, and it stays declared here
* because it *is* the ladder: a second copy inside the sheet is how the two would
* drift apart.
*/
const LINE_HEIGHT_LADDER = ['inherit', '1.4', '1.6', '1.8', '2.1'];
const THEME_LADDER: Array<AppSettings['theme']> = ['light', 'sepia', 'dark'];
const THEME_LABELS: Record<string, string> = { light: '白', sepia: '米黄', dark: '夜间' };
function nextTheme(theme: AppSettings['theme']): AppSettings['theme'] {
const index = THEME_LADDER.indexOf(theme);
return THEME_LADDER[(index + 1) % THEME_LADDER.length] ?? 'light';
}
/** The progress fraction as a percentage, clamped. */
function percentOf(fraction: number): string {
return `${Math.round(Math.min(1, Math.max(0, fraction)) * 100)}%`;
}
export type { SpeechEngineKind };
+41
View File
@@ -0,0 +1,41 @@
import { DEFAULT_APP_SETTINGS, READOUT_FIELDS, type ReadoutMode } from '../store/settings.ts';
import type { ChromeState } from './reader-chrome.tsx';
import { useEffect, useState } from './vendor/preact.ts';
/** Only this small subtree updates with the clock; the reading surface is untouched. */
export function ReaderIndicators({ state }: { state: ChromeState }) {
const modes = READOUT_FIELDS.map(({ key }) => state[key] ?? DEFAULT_APP_SETTINGS[key]);
const clockEnabled = modes.includes('time');
const [now, setNow] = useState(() => new Date());
useEffect(() => {
if (!clockEnabled) return;
let timer: ReturnType<typeof setTimeout>;
const refresh = () => {
clearTimeout(timer);
setNow(new Date());
timer = setTimeout(refresh, 60_000 - Date.now() % 60_000);
};
refresh();
document.addEventListener('visibilitychange', refresh);
return () => { clearTimeout(timer); document.removeEventListener('visibilitychange', refresh); };
}, [clockEnabled]);
const text = (mode: ReadoutMode): string => {
switch (mode) {
case 'book': return state.title;
case 'chapter': return state.chapterLabel;
case 'progress': return `第${Math.max(1, state.pageInChapter)}/${Math.max(1, state.chapterPages)}页 ${Math.round(Math.min(1, Math.max(0, state.progress)) * 100)}%`;
case 'time': return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
default: return '';
}
};
return <>{(['top', 'bottom'] as const).map((edge, index) => (
<div key={edge} className={`reading-indicator reading-indicator-${edge}`} aria-label={edge === 'top' ? '顶部阅读信息' : '底部阅读信息'}>
{(['left', 'right'] as const).map((side, offset) => {
const mode = modes[index * 2 + offset]!;
const value = text(mode);
return <span key={side} className={`indicator-${side}`} data-mode={mode} title={value}>{value}</span>;
})}
</div>
))}</>;
}
+17 -6
View File
@@ -9,7 +9,7 @@ import type { Platform } from '../core/platform.ts';
import { ReaderView, type Position, type ViewSettings } from './reader-view.ts';
import { makeAssetSigner } from '../net/asset-url.ts';
import { attachGestures } from './gestures.ts';
import type { AppSettings } from '../store/settings.ts';
import { READOUT_FIELDS, type AppSettings } from '../store/settings.ts';
import { createStagedDoc, isStagedKind, windowIndexOf } from '../formats/windowed.ts';
import type { BookDoc } from '../formats/types.ts';
import type { TocEntry } from '../api/types.ts';
@@ -218,7 +218,7 @@ export class ReaderScreen {
handlers={{
onBack: () => this.options.onBack(),
toggleToc: () => this.toggleToc(),
toggleSettings: () => this.toggleSettings(),
toggleSettings: (tab) => this.toggleSettings(tab),
onSetting: (patch) => void this.updateSetting(patch),
onSpeechSetting: (patch) => this.updateSpeechSetting(patch),
onVoice: (voice) => this.updateSpeechSetting({ ttsVoice: voice }),
@@ -926,9 +926,9 @@ export class ReaderScreen {
this.patch({ tocOpen: open, settingsOpen: false });
}
private toggleSettings(): void {
const open = !this.chrome.settingsOpen;
this.patch({ settingsOpen: open, tocOpen: false });
private toggleSettings(tab: 'appearance' | 'behavior' | 'speech' = 'behavior'): void {
const open = !this.chrome.settingsOpen || this.chrome.settingsTab !== tab;
this.patch({ settingsOpen: open, settingsTab: tab, tocOpen: false });
if (!open) return;
// Re-read the voice list on every open: a Bluetooth headset paired while the
// book was open adds a voice, and Chrome only reports it asynchronously.
@@ -1260,7 +1260,10 @@ export class ReaderScreen {
private async updateSetting(patch: Partial<AppSettings>): Promise<void> {
this.options.onSettingsChange(patch);
Object.assign(this.settings, patch);
this.view?.applySettings(this.viewSettings());
// Reading information changes only the chrome, never the book's pagination.
if (Object.keys(patch).some(key => !READOUT_FIELDS.some(field => field.key === key))) {
this.view?.applySettings(this.viewSettings());
}
this.patch({});
// Fixed-layout fit and direction changes are structural, so the current page
// has to be re-rendered rather than merely re-styled.
@@ -1686,6 +1689,10 @@ function emptyChromeSettings(): Partial<ChromeState> {
shelfSort: 'recent',
shelfShowAuthor: true,
shelfShowProgress: true,
readoutTopLeft: 'chapter',
readoutTopRight: 'none',
readoutBottomLeft: 'progress',
readoutBottomRight: 'time',
},
{ layout: 'reflowable', format: '', availability: { system: false, native: false, http: false, preferred: null }, engine: null, voices: [] },
);
@@ -1726,6 +1733,10 @@ function settingsView(
const activeEngine = context.engine ?? context.availability.preferred;
return {
mode: settings.mode,
readoutTopLeft: settings.readoutTopLeft,
readoutTopRight: settings.readoutTopRight,
readoutBottomLeft: settings.readoutBottomLeft,
readoutBottomRight: settings.readoutBottomRight,
fontScale: settings.fontScale,
lineHeight: settings.lineHeight,
theme: settings.theme,
+23 -4
View File
@@ -94,7 +94,7 @@ export interface ViewSettings {
mode: 'scroll' | 'paged';
fontScale: number;
lineHeight: string;
theme: 'light' | 'sepia' | 'dark';
theme: 'light' | 'sepia' | 'green' | 'dark';
/** Fixed-layout only. */
fit: 'contain' | 'width';
direction: 'ltr' | 'rtl';
@@ -362,6 +362,8 @@ export class ReaderView {
applySettings(next?: Partial<ViewSettings>): void {
const previous = this.settings;
// Capture the live position before typography changes its scroll extent.
if (next) this.measureWithin();
if (next) this.settings = { ...this.settings, ...next };
document.documentElement.dataset['theme'] = this.settings.theme;
this.host.setAttribute('data-paginated', String(this.settings.mode === 'paged'));
@@ -369,6 +371,7 @@ export class ReaderView {
// `inherit` is what keeps the author's line-height; only an explicit user
// choice replaces it.
this.container.style.setProperty('--reader-line-height', this.settings.lineHeight);
this.container.style.setProperty('--reader-txt-line-height', this.settings.lineHeight === 'inherit' ? '1.8' : this.settings.lineHeight);
// Same reasoning for the font stack: the default is `inherit`, so a book
// that ships its own stack keeps it. The reader's choice is offered first so
// a book that ships *nothing* looks like the rest of their library.
@@ -835,6 +838,7 @@ export class ReaderView {
...(section.path ? { path: section.path } : {}),
...(section.image ? { mediaType: section.image.mediaType } : {}),
...(section.image ? { bytes: section.image.bytes } : {}),
...(section.document ? { bytes: section.document.bytes, mediaType: section.document.mediaType } : {}),
fit: this.settings.fit,
});
if (drawn) {
@@ -862,6 +866,12 @@ export class ReaderView {
img.decoding = 'async';
img.draggable = false;
wrapper.append(img);
} else if (section.document) {
const frame = document.createElement('iframe');
frame.className = 'pdf-frame';
frame.title = section.label;
frame.src = this.objectUrl(section.document.bytes, section.document.mediaType);
wrapper.append(frame);
} else if (section.html) {
const frame = document.createElement('iframe');
frame.className = 'pdf-frame';
@@ -893,11 +903,18 @@ export class ReaderView {
if (this.settings.mode === 'paged') {
const width = scroller.scrollWidth;
if (width <= 0) return this.sectionOffset;
return Math.min(1, Math.max(0, scroller.scrollLeft / Math.max(1, width - scroller.clientWidth)));
const within = Math.min(1, Math.max(0, scroller.scrollLeft / Math.max(1, width - scroller.clientWidth)));
this.sectionOffset = within;
return within;
}
const limit = scroller.scrollHeight - scroller.clientHeight;
if (limit <= 0) return 0;
return Math.min(1, Math.max(0, scroller.scrollTop / limit));
const within = Math.min(1, Math.max(0, scroller.scrollTop / limit));
// ResizeObserver re-anchors after the viewport changes. Keep the latest
// position, rather than the offset used when the chapter was opened, so a
// chrome repaint cannot send a reader back to page one.
this.sectionOffset = within;
return within;
}
private restoreOffset(): void {
@@ -1107,7 +1124,9 @@ export class ReaderView {
// re-enters this method from the observer, and the two keep re-measuring and
// re-writing each other. Writing only a *different* value terminates: the second call
// computes the same trim, sees it, and returns without touching the DOM.
if (this.trimApplied === trim) return;
// Text rectangles are rounded to subpixels. Treat subpixel noise as the
// same trim so ResizeObserver cannot oscillate on an unchanged layout.
if (this.trimApplied !== null && Math.abs(this.trimApplied - trim) < 0.5) return;
this.trimApplied = trim;
this.host.style.setProperty('--reader-page-trim', `${trim}px`);
}
+2
View File
@@ -77,6 +77,8 @@ import { BOOK_RESOURCE_MARKER } from '../formats/book-resource.ts';
*/
const TXT_STYLESHEET = `
.txt-body {
font-size: 1.125em;
line-height: var(--reader-txt-line-height, 1.8);
/* Declared on the wrapper rather than on each paragraph so one property can be
changed in one place, and so a paragraph that the file's own text already
indented is not indented twice. */
+1
View File
@@ -350,6 +350,7 @@ describe('PDF loading', () => {
expect(doc.format).toBe('pdf');
expect(doc.layout).toBe('fixed');
expect(doc.sections).toHaveLength(1);
expect((doc.sections[0] as { document?: { bytes: Uint8Array } }).document?.bytes).toEqual(utf8('%PDF-1.7 fake'));
});
});
+4 -3
View File
@@ -319,7 +319,8 @@ describe('the icon box', () => {
const host = document.createElement('div');
render(Icon({ name: 'close' }), host);
const span = host.querySelector('.icon')!;
expect(span.textContent).toBe(ICON_CODEPOINTS.close);
expect(span.tagName.toLowerCase()).toBe('svg');
expect(span.children.length).toBeGreaterThan(0);
expect(span.getAttribute('aria-hidden')).toBe('true');
expect(span.hasAttribute('aria-label')).toBe(false);
});
@@ -417,7 +418,7 @@ describe('icon buttons', () => {
expect(button.getAttribute('aria-label')).toBe('目录');
const glyph = button.querySelector('.icon')!;
expect(glyph.getAttribute('aria-hidden')).toBe('true');
expect(glyph.textContent).toBe(ICON_CODEPOINTS.menu);
expect(glyph.tagName.toLowerCase()).toBe('svg');
});
it('draws every icon button from the icon set rather than from text', async () => {
@@ -428,6 +429,6 @@ describe('icon buttons', () => {
// A regression here is the whole point of the change: the old button took
// arbitrary children, and Unicode punctuation is what it was given.
expect(host.querySelector('.icon')).not.toBeNull();
expect(host.textContent).toBe(ICON_CODEPOINTS.close);
expect(host.querySelector('svg path')).not.toBeNull();
});
});
+11
View File
@@ -206,6 +206,17 @@ describe('serialisePage', () => {
});
describe('androidPageHost', () => {
it('gives the browser a PDF blob instead of an empty fixed page', async () => {
const { loadPdf } = await import('../src/formats/pdf.ts');
const pdf = await loadPdf(new TextEncoder().encode('%PDF-1.7 fixture'));
const reader = view(pdf);
try {
await reader.open(0);
const frame = flowOf(reader).querySelector('iframe');
expect(frame).not.toBeNull();
expect(frame?.src).toMatch(/^blob:/);
} finally { reader.dispose(); }
});
function bridge(overrides: Partial<AndroidBridge> = {}): AndroidBridge {
return {
shellVersion: () => 2,
+23 -1
View File
@@ -129,7 +129,7 @@ describe('ReaderChrome', () => {
const labels = [...(rail?.querySelectorAll('.rail-button') ?? [])].map((button) =>
button.getAttribute('aria-label'),
);
expect(labels).toEqual(['夜间', '字号', '听书']);
expect(labels).toEqual(['夜间']);
for (const gone of ['目录', '字体', '行距', '页边距', '阅读设置']) {
expect(labels).not.toContain(gone);
}
@@ -143,8 +143,29 @@ describe('ReaderChrome', () => {
expect([...tree.querySelectorAll('button')].find((button) => button.textContent?.includes('正在刷新目录'))?.disabled).toBe(true);
});
it('uses chapter pages for the slider even when the book has many fixed pages', () => {
const tree = paint({ layout: 'fixed', chapterIndex: 50, chapterCount: 100, chapterPages: 1, pageInChapter: 1 });
const slider = tree.querySelector<HTMLInputElement>('.progress-scrubber')!;
expect(slider.max).toBe('1');
expect(slider.value).toBe('1');
expect(slider.disabled).toBe(true);
expect(slider.getAttribute('aria-label')).toBe('章节内页数');
});
it('separates appearance and behavior settings and omits text controls for comics', () => {
let tree = paint({ settingsOpen: true, settingsTab: 'appearance' });
expect(tree.querySelector('.panel')?.textContent).toContain('浅绿');
expect(tree.querySelector('.panel')?.textContent).not.toContain('点击区域');
tree = paint({ settingsOpen: true, settingsTab: 'behavior' });
expect(tree.querySelector('.panel')?.textContent).toContain('点击区域');
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
tree = paint({ settingsOpen: true, settingsTab: 'appearance', layout: 'fixed' });
expect(tree.querySelector('.panel')?.textContent).not.toContain('字号');
});
it('draws every read-aloud control, including stop, in their own row', () => {
const tree = paint({
settingsOpen: true, settingsTab: 'speech',
tts: {
active: true,
state: 'playing',
@@ -178,6 +199,7 @@ describe('ReaderChrome', () => {
// ✕ read as "close this bar", which is not what stop does: the reader who wanted
// the voice to stop pressed it, the bar disappeared, and the book kept reading.
const tree = paint({
settingsOpen: true, settingsTab: 'speech',
tts: {
active: true,
state: 'playing',
+53
View File
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'preact/test-utils';
import { createElement as h, render } from '../src/ui/vendor/preact.ts';
import { ReaderIndicators } from '../src/ui/reader-indicators.tsx';
import type { ChromeState } from '../src/ui/reader-chrome.tsx';
import { SettingsStore } from '../src/store/settings.ts';
const container = document.createElement('div');
const state = {
title: '测试书名', chapterLabel: '第二章', pageInChapter: 3, chapterPages: 12, progress: 0.47,
} as ChromeState;
const texts = () => Array.from(container.querySelectorAll('span'), el => el.textContent);
afterEach(() => { act(() => render(null, container)); vi.useRealTimers(); });
describe('reading information', () => {
it('maps all four positions independently and allows hiding each one', () => {
act(() => render(h(ReaderIndicators, { state: { ...state,
readoutTopLeft: 'book', readoutTopRight: 'chapter',
readoutBottomLeft: 'progress', readoutBottomRight: 'none',
} }), container));
expect(texts()).toEqual(['测试书名', '第二章', '第3/12页 47%', '']);
act(() => render(h(ReaderIndicators, { state: { ...state,
readoutTopLeft: 'none', readoutTopRight: 'none',
readoutBottomLeft: 'none', readoutBottomRight: 'none',
} }), container));
expect(texts()).toEqual(['', '', '', '']);
expect(container.querySelectorAll('.reading-indicator')).toHaveLength(2);
});
it('refreshes time at the minute boundary and on resume, then cleans up', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 8, 20, 9, 5, 59));
act(() => render(h(ReaderIndicators, { state }), container));
expect(texts()[3]).toBe('09:05');
act(() => { vi.advanceTimersByTime(1000); });
expect(texts()[3]).toBe('09:06');
vi.setSystemTime(new Date(2026, 8, 20, 10, 15));
act(() => { document.dispatchEvent(new Event('visibilitychange')); });
expect(texts()[3]).toBe('10:15');
act(() => render(null, container));
expect(vi.getTimerCount()).toBe(0);
});
it('migrates older preferences and persists each position across store instances', async () => {
let saved = JSON.stringify({ theme: 'sepia', readoutTopRight: 'invalid' });
const kv = { get: async () => saved, set: async (_key: string, value: string) => { saved = value; }, remove: async () => {} };
const store = new SettingsStore(kv);
expect(await store.load()).toMatchObject({ theme: 'sepia', readoutTopLeft: 'chapter', readoutTopRight: 'none', readoutBottomLeft: 'progress', readoutBottomRight: 'time' });
await store.update({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
expect(await new SettingsStore(kv).load()).toMatchObject({ readoutTopLeft: 'time', readoutTopRight: 'book', readoutBottomLeft: 'none', readoutBottomRight: 'chapter' });
});
});
+11 -95
View File
@@ -111,42 +111,12 @@ describe('the reading surface fills the screen', () => {
}
});
it('makes each reading readout a full-width row flush to its edge', () => {
// "reading-indicator 应该是紧贴顶部/底部,并且占用一整行": a readout used to be a
// paper pill the width of its own text, floated a little inside the edge. A sticker
// that ends wherever the words end gives the chapter name and the page count two
// different right edges, and over the chapter's own heading it read as a fragment of
// body text rather than something the screen drew — the "很奇怪,不整洁" in the report.
//
// So each readout is a *row*: the paper spans the stage and the words are inset
// inside it. Asserted as the pair of properties that make it a row rather than a
// pill — full width, and no horizontal shrink — because either one alone leaves the
// old look reachable.
// The row geometry is one rule for both readouts, so it is read by its own
// selector list rather than by either name alone.
// The shared rule is reached by either name: `rule()` matches the selector list
// the rule declares, so the first of the two names finds the row geometry.
const row = rule('.indicator-chapter');
expect(row).not.toBe('');
expect(declaration(row, 'display')).toBe('block');
expect(declaration(row, 'width')).toBe('100%');
expect(declaration(row, 'background')).toBe('var(--reader-paper)');
});
it('puts the page insets on the readout text and not on the row', () => {
// The paper reaches the very edge of the glass — under the notch and the cutout —
// so the safe insets move the *text* inward instead of leaving a strip of page
// showing beside the band. The container therefore carries no padding at all: an
// inset on it would be the strip this avoids.
it('keeps reading information in fixed top and bottom strips', () => {
const box = rule('.reading-indicator');
expect(declaration(box, 'padding')).toBeUndefined();
expect(declaration(box, 'align-items')).toBe('stretch');
// The two rows carry the safe insets on their own inner edges, in their own
// rules — read from the sheet's text, because the selector is the *second* rule
// for each name and a by-name lookup returns the shared row rule above it.
const text = css.replace(/\/\*[\s\S]*?\*\//g, '');
expect(text).toMatch(/\.indicator-chapter \{[^}]*padding-block-start: calc\(0\.35rem \+ var\(--safe-top\)\)/);
expect(text).toMatch(/\.indicator-progress \{[^}]*padding-block-end: calc\(0\.35rem \+ var\(--safe-bottom\)\)/);
expect(declaration(rule('.reading-indicator-top'), 'top')).toBe('0');
expect(declaration(rule('.reading-indicator-bottom'), 'bottom')).toBe('0');
expect(declaration(box, 'display')).toBe('flex');
expect(declaration(box, 'pointer-events')).toBe('none');
});
it('paints the read-aloud bar above the page it floats over', () => {
@@ -169,65 +139,11 @@ describe('the reading surface fills the screen', () => {
expect(declaration(controls, 'flex')).toBe('1 1 0');
});
it('takes the readout rows out of the page instead of drawing over the text', () => {
// "reading-indicator 是浮动的,导致翻页后,遮挡了一部分文字".
//
// This is *not* a z-index question, and that is the whole point of asserting it
// here. The rows already won the paint order (see the `--reader-layer-readout`
// check above); the report is that winning it is not the same as the text being
// readable. The rows are 25px and 24px tall at the phone size this product is
// used at, and a line of body text is 19px — so in immersion the sentence under
// each row was cut through the middle of its glyphs, and no paint order makes
// half a line whole.
//
// The correction is that the reading surface is inset by the two rows while they
// are on screen, so the text that discloses is never under one of them. Asserted
// as the three things that make it work:
//
// 1. the tokens exist, so the row's height is a number the page can be inset by;
// 2. the inset is on `inset-block-end`/`-start` rather than on a height or a
// padding, because the host is `border-box` and a padding would shorten the
// content box while leaving `clientHeight` — the number the page arithmetic
// and the restored offset are built on — where it was;
// 3. the rows are `line-height: 1`, or the token is 20% short of the row and the
// last line of the page is cut by exactly that much.
const root = css.replace(/\/\*[\s\S]*?\*\//g, '');
expect(root).toMatch(/--reader-readout-top: calc\(/);
expect(root).toMatch(/--reader-readout-bottom: calc\(/);
const hidden = rule(".reader-screen[data-chrome='hidden'] .book-host");
expect(hidden).not.toBe('');
expect(declaration(hidden, 'inset-block-start')).toBe('var(--reader-readout-top)');
expect(declaration(hidden, 'inset-block-end')).toContain('var(--reader-readout-bottom)');
// Not a height and not a padding: see (2) above. Either would leave the measured
// box and the drawn box as two different numbers.
expect(declaration(hidden, 'height')).toBeUndefined();
expect(declaration(hidden, 'block-size')).toBeUndefined();
expect(declaration(hidden, 'padding')).toBeUndefined();
expect(declaration(hidden, 'padding-block-end')).toBeUndefined();
const row = rule('.indicator-chapter');
expect(declaration(row, 'line-height')).toBe('1');
});
it('ends the page on a whole line, from a trim the view publishes', () => {
// The second half of the same report, and the half the inset does *not* answer.
// A page whose height is not a whole multiple of the line height ends with its
// last line sticking out past the bottom edge, and the reading surface — which is
// a clipped box — cuts that line's descenders. The strip is then blamed for a cut
// it did not make, and moving the strip changes nothing.
//
// So the page's own height is floored to the text's line grid and the remainder is
// taken off the bottom edge as an inset. It is published by the view because
// `line-height` is `inherit` by default — the book's own value, which this
// stylesheet deliberately does not guess at — so the grid is only knowable from the
// rendered boxes.
const hidden = rule(".reader-screen[data-chrome='hidden'] .book-host");
const inset = declaration(hidden, 'inset-block-end') ?? '';
expect(inset).toContain('var(--reader-page-trim');
// An `inset-block-end` and not a `padding-block-end`: the host is `border-box`, so
// a padding would leave `clientHeight` untouched and the page would be measured one
// height and drawn another.
const trimUse = css.replace(/\/\*[\s\S]*?\*\//g, '');
expect(trimUse).toContain('--reader-page-trim');
expect(trimUse).not.toMatch(/padding-block-end:\s*var\(--reader-page-trim/);
it('uses the same reading geometry with chrome visible and hidden', () => {
expect(rule(".reader-screen[data-chrome='hidden'] .book-host")).toBe('');
const body = rule('.reader-screen .book-host');
expect(declaration(body, 'inset-block-start')).toBe('calc(1.8rem + var(--safe-top))');
expect(declaration(body, 'inset-block-end')).toContain('var(--reader-page-trim');
expect(declaration(body, 'inset-block-end')).toContain('var(--safe-bottom)');
});
});
+28
View File
@@ -122,6 +122,34 @@ describe('page turns in scroll mode', () => {
document.body.append(container);
});
it('keeps a turned page when a later viewport observation restores the offset', async () => {
let resize: (() => void) | undefined;
const previousObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = class {
constructor(callback: () => void) { resize = callback; }
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
const view = scrollingView(container, 4);
try {
await view.open(0, 0);
await view.next();
const before = view.position();
expect(before.pageInChapter).toBe(2);
resize?.();
expect(view.position().locator).toBe(before.locator);
expect(view.position().pageInChapter).toBe(2);
await view.seekPageInChapter(2);
view.applySettings({ theme: 'green' });
await new Promise(resolve => requestAnimationFrame(resolve));
expect(view.position().pageInChapter).toBe(3);
} finally {
view.dispose();
globalThis.ResizeObserver = previousObserver;
}
});
it('walks back page by page and reports the page it is on', async () => {
// The regression: `previous()` decided whether it had room by comparing
// `scrollTop` against 8, then *subtracted* 0.9 of a screen. The two numbers do
+1
View File
@@ -101,6 +101,7 @@ export class CDP {
deviceScaleFactor: scale,
mobile: true,
});
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
return cdp;
}
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
import { CDP } from './cdp.mjs';
import { createReviewServer, BOOK_ID, ILLUSTRATED_ID } from './server.mjs';
const server = createReviewServer({ port: 5201 });
const origin = await server.listen();
const cdp = await CDP.launch({ viewport: { width: 390, height: 844 }, scale: 2 });
const read = () => cdp.run(`
const host = document.querySelector('book-content');
const flow = host.shadowRoot.querySelector('.book-flow');
const scroller = host.dataset.paginated === 'true' ? flow : host;
const rect = host.getBoundingClientRect();
return { top: rect.top, height: rect.height, x: scroller.scrollLeft, y: scroller.scrollTop,
page: document.querySelector('.progress-scrubber').value,
pages: document.querySelector('.progress-scrubber').max,
chrome: document.querySelector('.reader-screen').dataset.chrome };
`);
try {
await cdp.navigate(`${origin}/#/shelf`);
await cdp.waitFor('document.querySelector(".login-screen") !== null');
await cdp.fill('input[autocomplete="username"]', 'review');
await cdp.fill('input[type="password"]', 'password12');
await cdp.click('button[type="submit"]');
await cdp.waitFor('document.querySelector(".book-card") !== null');
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
await cdp.sleep(500);
await cdp.tapMiddle();
await cdp.sleep(350);
await cdp.tapThird(0.85);
await cdp.sleep(350);
const before = await read();
assert.ok(Number(before.page) > 1, `must reach a later page: ${JSON.stringify(before)}`);
await cdp.tapMiddle();
await cdp.sleep(350);
const after = await read();
console.log(JSON.stringify({ before, after }));
assert.equal(after.page, before.page, 'middle tap must preserve page');
assert.equal(after.y, before.y, 'middle tap must preserve scroll offset');
assert.equal(after.top, before.top, 'chrome must not move the reading surface');
assert.equal(after.height, before.height, 'chrome must not resize the reading surface');
await mkdir('../docs/ui-review/mobile', { recursive: true });
await cdp.screenshot('../docs/ui-review/mobile/reader.png');
for (const label of ['目录', '界面', '设置', '朗读']) {
await cdp.clickText('.reader-actions button', label);
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
const opened = await read();
assert.equal(opened.y, after.y, `${label} must preserve scroll position`);
assert.equal(opened.height, after.height, `${label} must preserve surface height`);
const panel = await cdp.run(`const r=document.querySelector('.panel').getBoundingClientRect();return {top:r.top,bottom:r.bottom};`);
assert.ok(panel.top > 250 && panel.bottom <= 845, `sheet must leave a preview and fit the screen: ${JSON.stringify(panel)}`);
await cdp.screenshot(`../docs/ui-review/mobile/panel-${label}.png`);
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.waitFor('document.querySelector(".panel") === null');
assert.equal((await read()).y, after.y, `${label} close must preserve position`);
}
await cdp.clickText('.reader-actions button', '界面');
const beforeReadouts = await read();
for (const [label, mode] of [['顶部左侧','book'],['顶部右侧','chapter'],['底部左侧','time'],['底部右侧','progress']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
const next = await read();
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key], beforeReadouts[key], 'readout setting preserves '+key);
}
const modes = await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)');
assert.deepEqual(modes, ['book','chapter','time','progress']);
await cdp.screenshot('../docs/ui-review/mobile/readout-settings.png');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.tapMiddle();
await cdp.screenshot('../docs/ui-review/mobile/readout-custom.png');
await cdp.navigate(`${origin}/#/shelf`);
await cdp.navigate(`${origin}/#/book/${BOOK_ID}`);
await cdp.waitFor('Number(document.querySelector(".progress-scrubber")?.max) > 1');
assert.deepEqual(await cdp.evaluate('Array.from(document.querySelectorAll(".reading-indicator span"),el=>el.dataset.mode)'), modes, 'readout settings survive reopening');
await cdp.clickText('.reader-actions button', '界面');
for (const [label, mode] of [['顶部左侧','chapter'],['顶部右侧','none'],['底部左侧','progress'],['底部右侧','time']]) {
await cdp.run(`const el=document.querySelector('select[aria-label="${label}"]');el.value='${mode}';el.dispatchEvent(new Event('change',{bubbles:true}));`);
await cdp.sleep(100);
}
await cdp.clickText('.panel button', '浅绿');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.sleep(300);
assert.equal((await read()).page, after.page, 'theme must preserve page');
await cdp.tapMiddle();
await cdp.sleep(250);
await cdp.screenshot('../docs/ui-review/mobile/immersive-green.png');
for (let i=0; i<6; i++) {
const prior = await read();
await cdp.tapMiddle();
await cdp.sleep(150);
const next = await read();
for (const key of ['x','y','page','pages','top','height']) assert.equal(next[key],prior[key],`repeated toggle: ${key}`);
}
await cdp.tapMiddle();
await cdp.clickText('.reader-actions button', '设置');
await cdp.clickText('.panel button', '翻页');
await cdp.click('.panel button[aria-label="关闭"]');
await cdp.sleep(300);
await cdp.run(`const el=document.querySelector('.progress-scrubber');el.value='2';el.dispatchEvent(new Event('input',{bubbles:true}));`);
await cdp.sleep(250);
const paged = await read();
assert.equal(paged.page, '2', 'chapter slider reaches page two');
for(let i=0;i<4;i++) {
await cdp.tapMiddle();
await cdp.sleep(200);
const next=await read();
for(const key of ['x','y','page','pages','top','height']) assert.equal(next[key],paged[key],`paged toggle: ${key}`);
}
await cdp.tapThird(0.85);
await cdp.sleep(300);
assert.equal(Number((await read()).page),3,'tap advances one page');
await cdp.tapThird(0.15);
await cdp.sleep(300);
assert.equal((await read()).page,'2','reverse tap returns to same page');
await cdp.screenshot('../docs/ui-review/mobile/paged.png');
await cdp.clickText('.footer button', '下一章');
await cdp.sleep(350);
assert.equal((await read()).page,'1','next chapter starts at page one');
await cdp.navigate(`${origin}/#/book/${ILLUSTRATED_ID}`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
await cdp.sleep(300);
const epub=await read();
await cdp.tapMiddle();
await cdp.sleep(250);
const epubHidden=await read();
for(const key of ['x','y','page','pages','top','height']) assert.equal(epubHidden[key],epub[key],`EPUB toggle: ${key}`);
await cdp.screenshot('../docs/ui-review/mobile/epub.png');
await cdp.navigate(`${origin}/#/book/review-comic`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("img")?.naturalWidth > 0');
await cdp.sleep(200);
await cdp.tapThird(0.85);
await cdp.sleep(300);
const comic = await read();
const chapter = await cdp.evaluate('document.querySelector(".reader-heading span").textContent');
assert.equal(chapter, '第 2 页', 'comic page advances');
assert.equal(comic.page, '1', 'fixed page has one page inside its chapter');
assert.equal(comic.pages, '1', 'fixed chapter slider must not become a whole-book slider');
for (let i=0; i<4; i++) {
await cdp.tapMiddle();
await cdp.sleep(200);
assert.equal(await cdp.evaluate('document.querySelector(".reader-heading span").textContent'), chapter);
assert.equal((await read()).height, comic.height, 'fixed layout geometry stays stable');
}
await cdp.screenshot('../docs/ui-review/mobile/comic.png');
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 320, height: 568, deviceScaleFactor: 2, mobile: true });
await cdp.sleep(250);
for(const label of ['界面','设置']) {
await cdp.clickText('.reader-actions button', label);
await cdp.run(`await Promise.all(document.querySelector('.panel').getAnimations().map(a=>a.finished));`);
const overflow=await cdp.run(`const p=document.querySelector('.panel');return p.scrollWidth-p.clientWidth;`);
assert.ok(overflow <= 1, `${label} fits a narrow phone`);
await cdp.screenshot(`../docs/ui-review/mobile/narrow-${label}.png`);
await cdp.click('.panel button[aria-label="关闭"]');
}
await cdp.navigate(`${origin}/#/book/review-pdf`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector("iframe")?.src.startsWith("blob:")');
const pdf = await cdp.run(`const frame=document.querySelector('book-content').shadowRoot.querySelector('iframe');return (await fetch(frame.src).then(r=>r.text())).slice(0,8);`);
assert.equal(pdf, '%PDF-1.4', 'browser viewer receives the actual PDF bytes');
// The browser's PDF extension starts asynchronously after the frame commits.
await cdp.sleep(1500);
await cdp.screenshot('../docs/ui-review/mobile/pdf.png');
console.log('PASS: page and geometry survive chrome toggle');
} finally {
await cdp.close();
await server.close();
}
+8 -943
View File
@@ -39,8 +39,8 @@ const SCALE = 2;
const SCENES = [
{ name: '01-shelf', label: '书架', what: '第一屏:先看到书,再看到控件' },
{ name: '02-reader', label: '阅读页', what: '顶栏(图标+文字)/ 右侧快捷列 / 正文 / 底栏「章节内页数」滑杆与上一章下一章', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '顶栏、底栏、快捷列一起收起,正文占满,只留章节与页码读数', openBook: true, tapCenter: true },
{ name: '02-reader', label: '阅读页', what: '顶部书名 / 日夜快捷按钮 / 正文 / 本章滑杆与四个底部入口', openBook: true },
{ name: '03-reader-no-chrome', label: '阅读页 · 收起工具栏', what: '工具栏一起收起,正文保持原位置,底部只留书名、页码与进度', openBook: true, tapCenter: true },
{ name: '04-panel-toc', label: '目录 · 半屏', what: '下半屏,上半屏正文仍可见', openBook: true, openPanel: '目录' },
{ name: '05-panel-settings', label: '阅读设置 · 半屏', what: '一行式行:标签在左、控件在右', openBook: true, openPanel: '设置' },
{ name: '06-reader-paged', label: '阅读页 · 翻页模式', what: '分栏后的一页,页数应与可翻次数一致', openBook: true, choose: ['设置', '翻页'], closePanel: true },
@@ -51,7 +51,7 @@ const SCENES = [
label: 'TXT · 正文排版',
what: '纯文本专属的缩进/段间距/编码三行,且面板仍是半屏',
openBook: true,
openPanel: '设置',
openPanel: '界面',
},
// The two list screens, as a reader flips between them.
//
@@ -145,934 +145,7 @@ async function audit(cdp, origin, scenes, results) {
if (!ok) failures.push(`${name}: ${detail}`);
};
// The chrome is one state, not two *or three*.
//
// The request behind this is specific — the top bar and the bottom bar hide
// together — and the failure it guards against is the one a reader reports as
// "只收起了一半": a header that leaves the text full height while a footer keeps
// covering the last two lines. So the collapse is asserted on both bands and on
// the space they occupied, in the state where the reader asked for immersion.
//
// The rail is the third band, and the follow-up report was that it *didn't* go
// with them: shown only while the chrome was hidden, it was the one piece of
// chrome that survived immersion, so a reader who tapped to get "just the page"
// still had a floating column over the text. It is asserted in both directions
// here — present with the bars, gone with the bars — because a control that only
// ever appears is exactly the state that was reported.
//
// Asked for the way the reader asks for it (a tap on the middle third) rather than
// by setting the attribute: the tap is also a thing that has to keep working.
await cdp.sleep(300);
// The chapter's first block, with the chrome up. Held so the assertion below can
// compare it against the same measurement taken with the chrome hidden.
const visibleChromeTextTop = await cdp.evaluate(`(() => {
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
const first = flow?.querySelector('p, h1, h2, h3');
return first ? Math.round(first.getBoundingClientRect().top) : null;
})()`);
const visibleChrome = await cdp.evaluate(`(() => {
const rail = document.querySelector('.reader-rail');
if (!rail) return { missing: true };
const r = rail.getBoundingClientRect();
return {
present: true,
visible: getComputedStyle(rail).visibility !== 'hidden' && r.width > 0 && r.height > 0,
buttons: rail.querySelectorAll('button').length,
pointerEvents: getComputedStyle(rail).pointerEvents,
};
})()`);
check(
'展开工具栏: 快捷按钮与两条栏一起出现',
!visibleChrome.missing && visibleChrome.visible && visibleChrome.buttons > 0 && visibleChrome.pointerEvents !== 'none',
visibleChrome.missing
? '没有找到快捷按钮列'
: `visible=${visibleChrome.visible} 按钮 ${visibleChrome.buttons} 个 pointer-events=${visibleChrome.pointerEvents}`,
);
/*
* The rail holds the two adjustments a reader makes *from the page* plus 听书, and
* nothing that opens a sheet.
*
* The report is "竖排工具栏去掉 目录、字体、行距、边距、设置这几个按钮", and the
* reasoning behind it is the one worth keeping in a comment: all five are
* *destinations*, not adjustments — 目录 and 设置 open a sheet, and 字体/行距/边距 are
* steps of values whose full range lives in that sheet — so a rail that also carried
* them was a second, worse copy of the settings panel floating over the text. What
* is left is what a reader changes without wanting to open anything.
*
* The column still has to stay inside the page however short the screen is, and the
* controls it cannot fit still have to be reachable — the column scrolls, and it
* looks scrollable, because a control that can only be found by scrolling has to
* advertise the scroll or it does not exist as far as the reader is concerned.
*/
const rail = await cdp.evaluate(`(() => {
const el = document.querySelector('.reader-rail');
if (!el) return { missing: true };
const r = el.getBoundingClientRect();
const style = getComputedStyle(el);
const labels = [...el.querySelectorAll('button')].map((b) => b.getAttribute('aria-label') ?? '');
return {
labels,
top: r.top,
bottom: r.bottom,
viewport: window.innerHeight,
inside: r.bottom <= window.innerHeight + 1 && r.top >= -1,
scrollable: style.overflowY === 'auto' || style.overflowY === 'scroll',
overflows: el.scrollHeight - el.clientHeight > 1,
scrollbarWidth: el.offsetWidth - el.clientWidth,
};
})()`);
check(
'快捷列: 整组控件都在页内',
!rail.missing && rail.inside,
rail.missing ? '没有找到快捷按钮列' : `列 y=${Math.round(rail.top)}..${Math.round(rail.bottom)} / 屏高 ${rail.viewport}`,
);
check(
'快捷列: 装不下的控件有滚动条,说明还有更多',
!rail.missing && (!rail.overflows || (rail.scrollable && rail.scrollbarWidth > 0)),
rail.missing
? '没有找到快捷按钮列'
: `溢出 ${rail.overflows ? '是' : '否'} overflow-y=${rail.scrollable} 滚动条 ${rail.scrollbarWidth}px`,
);
check(
'快捷列: 只留页面内就能完成的调整',
!rail.missing &&
rail.labels.includes('听书') &&
rail.labels.some((label) => label === '夜间' || label === '日间') &&
rail.labels.includes('字号'),
rail.missing ? '没有找到快捷按钮列' : `按钮 ${rail.labels.length} 个:${rail.labels.join(' / ')}`,
);
// The five the report asked to remove. Asserted by *name* rather than by count,
// because "four controls" would pass on a rail that had dropped the wrong one.
check(
'快捷列: 不含打开面板的五个入口',
!rail.missing && !rail.labels.some((label) => ['目录', '字体', '行距', '页边距', '设置', '阅读设置'].includes(label)),
rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`,
);
// The read-aloud control is on the rail, which is the report's own example
// ("听书"). It matters because it is the one adjustment a reader makes *without*
// leaving the page: reaching it only through the settings sheet is a different
// product.
check(
'快捷列: 听书在列内',
!rail.missing && rail.labels.includes('听书'),
rail.missing ? '没有找到快捷按钮列' : `列内控件:${rail.labels.join(' / ')}`,
);
/*
* The same column on a short screen, which is the screen the report came from.
*
* A phone in landscape, or a phone whose browser chrome and status bar leave it
* 520px of page, is where a column with no height ceiling stops showing its own
* contents — and it is the case a review at a single 844px portrait size can never
* see. Asserted by *scrolling the column* and asking where the last control ended
* up, rather than by reading `overflow-y` back: the CSS is a claim about intent and
* this is a claim about the reader being able to reach the control.
*/
await cdp.send('Emulation.setDeviceMetricsOverride', { width: 390, height: 420, deviceScaleFactor: 2, mobile: true });
await cdp.sleep(400);
const shortRail = await cdp.evaluate(`(() => {
const el = document.querySelector('.reader-rail');
if (!el) return { missing: true };
const r = el.getBoundingClientRect();
const buttons = [...el.querySelectorAll('button')];
el.scrollTop = el.scrollHeight;
const last = buttons[buttons.length - 1]?.getBoundingClientRect();
const col = el.getBoundingClientRect();
return {
bottom: r.bottom,
viewport: window.innerHeight,
inside: r.bottom <= window.innerHeight + 1,
reachable: last ? last.bottom <= col.bottom + 1 && last.top >= col.top - 1 : false,
count: buttons.length,
scrollbarWidth: el.offsetWidth - el.clientWidth,
};
})()`);
check(
'快捷列(420px 短屏): 列在页内,超出的控件能滚到',
!shortRail.missing && shortRail.inside && shortRail.reachable,
shortRail.missing
? '没有找到快捷按钮列'
: `列底 ${Math.round(shortRail.bottom)} / 屏高 ${shortRail.viewport},滚动条 ${shortRail.scrollbarWidth}px,`
+ `滚到底后末个控件可见=${shortRail.reachable}`,
);
await cdp.send('Emulation.setDeviceMetricsOverride', { width: VIEWPORT.width, height: VIEWPORT.height, deviceScaleFactor: SCALE, mobile: true });
await cdp.sleep(300);
await cdp.tapMiddle();
await cdp.sleep(700);
const hidden = await cdp.evaluate(`(() => {
const screen = document.querySelector('.reader-screen');
const topbar = document.querySelector('.topbar');
const footer = document.querySelector('.footer');
const stage = document.querySelector('.stage');
if (!screen || !topbar || !footer || !stage) return { missing: true };
const t = topbar.getBoundingClientRect();
const f = footer.getBoundingClientRect();
const s = stage.getBoundingClientRect();
return {
attribute: screen.dataset.chrome,
topbarHeight: Math.round(t.height),
footerHeight: Math.round(f.height),
topbarVisibility: getComputedStyle(topbar).visibility,
footerVisibility: getComputedStyle(footer).visibility,
topbarPointerEvents: getComputedStyle(topbar).pointerEvents,
footerPointerEvents: getComputedStyle(footer).pointerEvents,
topbarPosition: getComputedStyle(topbar).position,
footerPosition: getComputedStyle(footer).position,
stageTop: Math.round(s.top),
stageBottom: Math.round(s.bottom),
viewportHeight: window.innerHeight,
textTop: (() => {
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
const first = flow?.querySelector('p, h1, h2, h3');
return first ? Math.round(first.getBoundingClientRect().top) : null;
})(),
rail: (() => {
const el = document.querySelector('.reader-rail');
if (!el) return null;
const r = el.getBoundingClientRect();
return {
present: true,
visible: getComputedStyle(el).visibility !== 'hidden' && r.width > 0 && r.height > 0,
pointerEvents: getComputedStyle(el).pointerEvents,
};
})(),
indicatorTop: (() => {
const el = document.querySelector('.indicator-chapter');
return el ? Math.round(el.getBoundingClientRect().height) : null;
})(),
indicator: (() => {
const el = document.querySelector('.reading-indicator');
if (!el) return null;
const r = el.getBoundingClientRect();
const chapter = el.querySelector('.indicator-chapter');
const progress = el.querySelector('.indicator-progress');
return {
visible: getComputedStyle(el).visibility !== 'hidden' && r.height > 0,
pointerEvents: getComputedStyle(el).pointerEvents,
chapter: chapter ? chapter.textContent.trim() : '',
progress: progress ? progress.textContent.trim() : '',
};
})(),
};
})()`);
const hiddenTextTop = hidden.missing ? null : hidden.textTop;
const hiddenTopInset = hidden.missing ? null : hidden.indicatorTop;
check(
'收起工具栏: 顶栏与底栏一起走',
!hidden.missing && hidden.attribute === 'hidden' && hidden.topbarVisibility === 'hidden' && hidden.footerVisibility === 'hidden',
hidden.missing ? '没有找到阅读页' : `state=${hidden.attribute} 顶栏 ${hidden.topbarVisibility} / 底栏 ${hidden.footerVisibility}`,
);
check(
'收起工具栏: 两条栏都不可见,也不可点',
!hidden.missing && hidden.topbarPointerEvents === 'none' && hidden.footerPointerEvents === 'none',
hidden.missing ? 'n/a' : `topbar=${hidden.topbarPointerEvents} footer=${hidden.footerPointerEvents}`,
);
// The page's *drawn* geometry must not move when the chrome does: showing or hiding
// the toolbar is a repaint, not a re-measure, and the reader reported it as the text
// jumping under their finger.
//
// Measured on the *slot* rather than on the chapter's first block, and the change is
// the answer to a report that came later: "reading-indicator 是浮动的,导致翻页后,遮挡
// 了一部分文字". The immersion readouts are 25px and 24px tall, and a line of body text
// is 19px, so the two rows used to be *drawn over* the sentence at each end of the
// page — cut through the middle of its glyphs. Making the rows readable means the page
// makes room for them, and making room means the first line moves down by exactly the
// height of the top row.
//
// What must stay true is that the movement is *exactly* that, and the same at both
// ends: the reading surface is the slot less the two rows, so the text is not
// re-measured, re-typeset or re-paginated — only looked at through a shorter window.
// Asserting "no movement at all" would be asserting the defect.
check(
'收起工具栏: 正文只是让出读数行的高度,没有被重新排版',
!hidden.missing &&
visibleChromeTextTop !== null &&
hiddenTextTop !== null &&
hiddenTopInset !== null &&
Math.abs(hiddenTextTop - visibleChromeTextTop - hiddenTopInset) <= 2,
hidden.missing || visibleChromeTextTop === null || hiddenTextTop === null || hiddenTopInset === null
? 'n/a'
: `正文首行 y: 显示时 ${visibleChromeTextTop} / 收起后 ${hiddenTextTop},读数行高 ${hiddenTopInset}px`,
);
// And the page *fills* the screen, which is the other half of the report:
// "内容要铺满,不要预留 stage". The stage used to carry the bars' own heights as
// `padding-block`, so the reading column was permanently 9rem shorter than the
// phone in *both* chrome states — a reader who had hidden the toolbar and wanted
// the whole screen for the book still lost the top and bottom bands to empty
// paper. Measured as "the stage reserves no block padding", which is the cause,
// rather than as "the text reaches the edge", which a chapter's own margin makes
// false.
const stageBox = await cdp.evaluate(`(() => {
const stage = document.querySelector('.stage-host');
const host = document.querySelector('.stage');
if (!stage || !host) return null;
const style = getComputedStyle(stage);
return {
paddingTop: parseFloat(style.paddingTop) || 0,
paddingBottom: parseFloat(style.paddingBottom) || 0,
stageTop: Math.round(host.getBoundingClientRect().top),
stageBottom: Math.round(host.getBoundingClientRect().bottom),
viewport: window.innerHeight,
};
})()`);
check(
'正文铺满: 页面不为工具栏预留高度',
stageBox !== null &&
stageBox.paddingTop <= 0.5 &&
stageBox.paddingBottom <= 0.5 &&
stageBox.stageBottom - stageBox.stageTop >= stageBox.viewport - 2,
stageBox === null
? 'n/a'
: `padding ${stageBox.paddingTop}/${stageBox.paddingBottom}px, 页面 ${stageBox.stageBottom - stageBox.stageTop}px / 屏高 ${stageBox.viewport}`,
);
// The bars float over the page, so they must be *positioned* — a bar back in the
// flow is a bar that takes height out of the page and moves the text, which is the
// whole of the reported defect. Asserted on the computed `position` rather than on
// the text not moving, because the two are different facts: a bar could be in flow
// and the text still not move if something else absorbed the height, and the next
// person to touch this layout needs the reason to be the assertion.
check(
'工具栏是浮动层,不占正文的高度',
!hidden.missing && hidden.topbarPosition === 'absolute' && hidden.footerPosition === 'absolute',
hidden.missing ? 'n/a' : `topbar=${hidden.topbarPosition} footer=${hidden.footerPosition}`,
);
// The rail goes with the bars, in this direction too. `visibility: hidden` is
// asserted rather than merely "height is zero", because a control a finger can
// still land on is a control that is still there — and the state being guarded
// against is precisely "the rail outlived the toolbar".
check(
'收起工具栏: 快捷按钮跟着两条栏一起收',
!hidden.missing && hidden.rail !== null && !hidden.rail.visible && hidden.rail.pointerEvents === 'none',
hidden.missing
? 'n/a'
: hidden.rail
? `visible=${hidden.rail.visible} pointer-events=${hidden.rail.pointerEvents}`
: '没有找到快捷按钮列',
);
// Immersion keeps the *where am I* readouts. Hiding the chrome should leave a
// reader who knows which chapter they are in and how far through — not a blank
// screen they have to tap to interrogate.
check(
'收起工具栏: 保留章节与页码指示',
!hidden.missing &&
hidden.indicator !== null &&
hidden.indicator.visible &&
hidden.indicator.chapter.length > 0 &&
hidden.indicator.progress.length > 0,
hidden.missing
? 'n/a'
: hidden.indicator
? `chapter="${hidden.indicator.chapter}" progress="${hidden.indicator.progress}"`
: '没有找到阅读指示',
);
// The indicator is a readout, not a control: a tap on it has to fall through to
// the page-turning zone underneath, or hiding the chrome would create a dead band.
check(
'收起工具栏: 阅读指示不吃手势',
!hidden.missing && hidden.indicator !== null && hidden.indicator.pointerEvents === 'none',
hidden.missing ? 'n/a' : `pointer-events=${hidden.indicator?.pointerEvents ?? 'missing'}`,
);
// The two readouts left on screen in immersion must not be *covered*, and the
// report is specific: the chapter name and the page count were painted over by
// the chapter's own text.
//
// Measured as **paint order**, not as a hit test, and the difference is the whole
// difficulty of the check. The readouts are `pointer-events: none` — deliberately,
// so a tap on the chapter name turns the page rather than being swallowed — and
// `elementFromPoint` skips anything that opts out of hit testing, so a hit test
// can *never* see them and would report "covered" for a readout drawn on top of
// everything. What the browser actually does is paint the two boxes in order, and
// the number that decides the order is the `z-index` the two boxes *resolve to*
// within the screen's stacking context. So that is what is compared.
//
// This is how the defect was possible at all: the reading surface is a positioned
// sibling and it comes *later* in the tree, so with neither box declaring a
// `z-index` the page was painted last — over the two numbers describing it.
const indicator = await cdp.evaluate(`(() => {
const root = document.querySelector('.reader-screen');
const host = document.querySelector('.book-host');
if (!root || !host) return { missing: true };
// The z-index each of the four boxes resolves to, taken from the nearest box
// that declares one — which is how the browser answers the same question.
const effective = (node) => {
let current = node;
while (current && current !== root) {
const value = getComputedStyle(current).zIndex;
if (value !== 'auto' && value !== '') return Number(value);
current = current.parentElement;
}
return 0;
};
const out = { page: effective(host) };
for (const [name, selector] of [['chapter', '.indicator-chapter'], ['progress', '.indicator-progress']]) {
const el = document.querySelector(selector);
if (!el) { out[name] = { missing: true }; continue; }
const r = el.getBoundingClientRect();
out[name] = {
text: el.textContent.trim(),
top: Math.round(r.top),
bottom: Math.round(r.bottom),
left: Math.round(r.left),
layer: effective(el),
// The paper behind the label, so a line of the chapter underneath cannot
// show through the number the reader is trying to read.
background: getComputedStyle(el).backgroundColor,
};
}
return out;
})()`);
for (const [name, label] of [['chapter', '章节名'], ['progress', '页码读数']]) {
const reading = indicator[name];
check(
`收起工具栏: ${label}画在正文之上`,
!reading?.missing && !indicator.missing && reading.layer > indicator.page,
indicator.missing || reading?.missing
? '没有找到阅读指示或阅读面'
: `层级 ${reading.layer} vs 正文 ${indicator.page},y=${reading.top}..${reading.bottom} 文本="${reading.text}"`,
);
check(
`收起工具栏: ${label}自带底色,不被正文透出`,
!reading?.missing && reading.background !== 'rgba(0, 0, 0, 0)' && reading.background !== 'transparent',
reading?.missing ? '没有找到阅读指示' : `background=${reading.background}`,
);
}
// Both readouts are at the *same* left edge, and that edge is inset from the
// screen: two lines stacked at the bottom of a page are read as one column, and
// the page count used to sit 4px to the right of the chapter name above it.
const indicatorAligned = await cdp.evaluate(`(() => {
const a = document.querySelector('.indicator-chapter')?.getBoundingClientRect();
const b = document.querySelector('.indicator-progress')?.getBoundingClientRect();
if (!a || !b) return null;
return { chapterLeft: Math.round(a.left), progressLeft: Math.round(b.left) };
})()`);
check(
'收起工具栏: 章节名与页码左边对齐',
indicatorAligned !== null && Math.abs(indicatorAligned.chapterLeft - indicatorAligned.progressLeft) <= 1,
indicatorAligned
? `章节名 left=${indicatorAligned.chapterLeft} 页码 left=${indicatorAligned.progressLeft}`
: '没有找到阅读指示',
);
// "reading-indicator 应该是紧贴顶部/底部,并且占用一整行".
//
// Both halves of that are geometry, and both were wrong: each readout was a paper
// pill the width of its own words, floated ~10px inside the edge. A pill ends
// wherever the words end, so the chapter name and the page count had two different
// right edges, and neither reached the edge of the glass. The row is what makes them
// read as something the screen drew rather than as fragments of the page, so it is
// asserted as three numbers: full width, flush to its own edge, and one row each.
const indicatorRows = await cdp.evaluate(`(() => {
const stage = document.querySelector('.stage')?.getBoundingClientRect();
const a = document.querySelector('.indicator-chapter')?.getBoundingClientRect();
const b = document.querySelector('.indicator-progress')?.getBoundingClientRect();
if (!stage || !a || !b) return null;
return {
width: Math.round(stage.width),
chapter: { top: Math.round(a.top), left: Math.round(a.left), width: Math.round(a.width) },
progress: { bottom: Math.round(b.bottom), right: Math.round(b.right), width: Math.round(b.width) },
viewportBottom: window.innerHeight,
};
})()`);
check(
'收起工具栏: 章节名占满一整行并紧贴顶部',
indicatorRows !== null &&
Math.abs(indicatorRows.chapter.width - indicatorRows.width) <= 1 &&
indicatorRows.chapter.top <= 1 &&
indicatorRows.chapter.left <= 1,
indicatorRows
? `行 ${indicatorRows.chapter.width}px / 屏宽 ${indicatorRows.width},top=${indicatorRows.chapter.top}`
: '没有找到阅读指示',
);
check(
'收起工具栏: 页码占满一整行并紧贴底部',
indicatorRows !== null &&
Math.abs(indicatorRows.progress.width - indicatorRows.width) <= 1 &&
Math.abs(indicatorRows.progress.bottom - indicatorRows.viewportBottom) <= 1 &&
indicatorRows.progress.right >= indicatorRows.width - 1,
indicatorRows
? `行 ${indicatorRows.progress.width}px / 屏宽 ${indicatorRows.width},bottom=${indicatorRows.progress.bottom}/${indicatorRows.viewportBottom}`
: '没有找到阅读指示',
);
// A tap in an outer third pages, and *only* pages.
//
// The report is "在点击左右两边时只需要翻页、不需要显示工具栏,只有在中间点击时才需要
// 切换工具栏显隐", and it is one tap described from both of the directions it can
// fail. The outer thirds used to page-turn *and* force the chrome back while it
// was hidden, on the reasoning that a hidden bar leaves no other way back — so the
// immersive state was impossible to stay in, because every page turn undid it.
// Both halves have to be asserted: a check on the page number alone passes on the
// behaviour that was reported, and so does a check on the chrome alone.
//
// The chrome is hidden first, so the assertion is about a state the reader can
// actually be in, and the page readout is read before and after.
//
// The readout is the *immersion* one, and that is the repair rather than a detail:
// `.progress-page` is the footer's own label, and the footer is `visibility: hidden`
// with the chrome down — `textContent` on a hidden element is still the last value it
// was given, so reading it in this state compared the page *before* the tap with
// itself and reported "does not advance" for a turn that had worked all along. The
// page count that is on screen while the chrome is hidden is the one the reader is
// looking at, and it is the one this has to read.
const pageReadout = `(() => ({
chrome: document.querySelector('.reader-screen').dataset.chrome,
page: document.querySelector('.indicator-progress')?.textContent.trim() ?? '',
value: Number(document.querySelector('.progress-scrubber')?.value ?? '0'),
}))()`;
// Read on a reader in **scroll** mode, which is the mode a page turn turns a
// *screenful* in, and the mode the scene above left behind. A 翻页 scene selects the
// paged layout, in which the chapter is one screen wide and a tap in the right third
// moves between columns — a page turn that changes what is on screen without changing
// which page of the chapter the reader is *on*, because paged mode reports one page
// per chapter screen... unless the chapter really has several, which this fixture's
// does not. The assertion is about a number that advances, so it is made where a
// number advances: a scroll-mode chapter of several screens.
//
// Set through the panel rather than by reaching into the view, because the mode is a
// setting and the panel is how a reader changes it.
await cdp.tapMiddle();
await cdp.sleep(400);
await cdp.click('button[aria-label="设置"]');
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.clickText('.segmented button', '滚动');
await cdp.sleep(400);
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(400);
await cdp.tapMiddle();
await cdp.sleep(500);
const beforeThird = await cdp.evaluate(pageReadout);
await cdp.tapThird(0.85);
await cdp.sleep(600);
const afterThird = await cdp.evaluate(pageReadout);
check(
'点右侧只翻页: 页码前进, 工具栏保持收起',
afterThird.chrome === 'hidden' && beforeThird.chrome === 'hidden' && afterThird.page !== beforeThird.page,
`收起前 ${beforeThird.chrome}(${beforeThird.page}) → 点击后 ${afterThird.chrome}(${afterThird.page})`,
);
/*
* And the readouts do not *cover* the text — "reading-indicator 是浮动的,导致翻页后,
* 遮挡了一部分文字".
*
* The check above is about paint order and it passed while this defect was fully
* reproducible, which is exactly why this one exists: a readout drawn on top of a
* line is legible, and the line under it is cut through the middle of its glyphs.
* Whether the reader can read the page is a *different* question from which of the
* two boxes wins, and it is the one the report asked.
*
* Measured as geometry, and on the real text rather than on a proxy: every line box
* in the chapter is compared against the two rows, and a line that crosses a row's
* edge is a line the reader is looking at half of. A tolerance of one pixel absorbs
* the sub-pixel rounding the browser does on `getClientRects`, and the two rows are
* taken from their own rects rather than from a constant, so this check cannot agree
* with a row height that no longer exists.
*
* A *page turn* is performed first, because that is where the report is: the first
* page of a chapter can be clean while every page after it is not, if the turn lands
* mid-line. So the turn is part of the check rather than a precondition of it — and it
* is a turn in the reader's own gesture (a tap in the outer third), not a scroll
* assignment, so the arithmetic that decides where a page starts is the one under
* test.
*/
await cdp.tapThird(0.85);
await cdp.sleep(500);
const covered = await cdp.evaluate(`(() => {
const flow = document.querySelector('book-content')?.shadowRoot?.querySelector('.book-flow');
const rows = ['.indicator-chapter', '.indicator-progress'].map((selector) => {
const el = document.querySelector(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
return { selector, top: r.top, bottom: r.bottom, text: el.textContent.trim() };
}).filter(Boolean);
if (!flow || rows.length === 0) return { missing: true };
const eps = 1;
const cuts = [];
for (const block of flow.querySelectorAll('p, h1, h2, h3, li, blockquote')) {
const range = document.createRange();
range.selectNodeContents(block);
const rects = typeof range.getClientRects === 'function' ? range.getClientRects() : [];
for (const rect of rects) {
for (const row of rows) {
// A line is cut when it *crosses* an edge of a row: entirely above or
// entirely below is a line on another page, which is not a defect.
if (rect.top < row.bottom - eps && rect.bottom > row.bottom + eps) cuts.push({ row: row.selector, text: block.textContent.trim().slice(0, 12), line: [Math.round(rect.top), Math.round(rect.bottom)], edge: Math.round(row.bottom) });
if (rect.top < row.top - eps && rect.bottom > row.top + eps) cuts.push({ row: row.selector, text: block.textContent.trim().slice(0, 12), line: [Math.round(rect.top), Math.round(rect.bottom)], edge: Math.round(row.top) });
}
}
}
return { missing: false, rows: rows.map((r) => [Math.round(r.top), Math.round(r.bottom)]), cuts };
})()`);
check(
'收起工具栏: 翻页后正文不被读数行拦腰截断',
covered.missing === false && covered.cuts.length === 0,
covered.missing
? '没有找到阅读面或阅读指示'
: covered.cuts.length === 0
? `读数行 ${JSON.stringify(covered.rows)},没有半截行`
: `有 ${covered.cuts.length} 行被截断:${JSON.stringify(covered.cuts.slice(0, 3))}`,
);
// The middle third is the only way back to the controls, so it is asserted as
// such right after: the outer-third check above leaves the chrome hidden, and the
// panel checks below need it up.
await cdp.tapMiddle();
await cdp.sleep(600);
const afterMiddle = await cdp.evaluate(`document.querySelector('.reader-screen').dataset.chrome`);
check('点中间切换工具栏', afterMiddle === 'visible', `点击中间后 ${afterMiddle}`);
// A page turn must not lose the offset it just moved to.
//
// The report is "虽然有左右翻页的动画,但是动画结束后又有一个重新渲染章节内容并滚动到
// 下一页的动画": a slide, then the page jumping to where it was always going. The cause
// was the clip on `data-animating` — it sat on `.book-flow`, which *is* the horizontal
// scroller in paged mode, so setting the attribute replaced the scroll container with
// one that cannot scroll and discarded `scrollLeft`. The animation played over the old
// page, and the offset came back a frame after the attribute was cleared.
//
// Asserted by *holding the attribute* rather than by racing the animation: the offset
// has to survive the animating state itself, which is the property, and a timing check
// would only catch it on a machine slow enough to observe the wrong frame. The reader
// is switched to 翻页 mode first, because that is the mode whose scroller is the flow.
// The chrome is visible here — the check just above put it there — so the settings
// button is on screen and can be clicked.
await cdp.click('button[aria-label="设置"]');
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.clickText('.segmented button', '翻页');
await cdp.sleep(300);
await cdp.click('button[aria-label="关闭"]');
await cdp.sleep(400);
const turnKeepsOffset = await cdp.run(`
const host = document.querySelector('book-content');
const flow = host.shadowRoot.querySelector('.book-flow');
if (!flow) return { missing: true };
// Move to the second page by the same arithmetic the turn uses, then hold the
// animating state the way the turn does and read the offset back.
flow.scrollLeft = flow.clientWidth;
const target = Math.round(flow.scrollLeft);
host.setAttribute('data-animating', 'slide-next-x');
const during = Math.round(flow.scrollLeft);
const overflow = getComputedStyle(flow).overflow;
host.removeAttribute('data-animating');
const after = Math.round(flow.scrollLeft);
flow.scrollLeft = 0;
return { target, during, after, overflow };
`);
check(
'翻页: 动画期间不丢滚动位置(动画后不再跳页)',
!turnKeepsOffset.missing && turnKeepsOffset.during === turnKeepsOffset.target && turnKeepsOffset.target > 0,
turnKeepsOffset.missing
? '没有找到阅读面'
: `目标 ${turnKeepsOffset.target} → 动画中 ${turnKeepsOffset.during} → 动画后 ${turnKeepsOffset.after},overflow=${turnKeepsOffset.overflow}`,
);
for (const scene of scenes) {
if (scene.openPanel) {
// Opened here rather than assumed open: the chrome checks above interact with
// the page, and a sheet they left behind would make the tap that hides the
// chrome land on the sheet's backdrop instead.
await cdp.click(`button[aria-label="${scene.openPanel}"]`);
await cdp.waitFor('document.querySelector(".panel") !== null', 10_000);
await cdp.sleep(400);
const geometry = await cdp.evaluate(`(() => {
const panel = document.querySelector('.panel');
const scrim = document.querySelector('.scrim');
const stage = document.querySelector('.stage');
if (!panel) return null;
const p = panel.getBoundingClientRect();
const s = stage.getBoundingClientRect();
return {
panelTop: p.top,
panelHeight: p.height,
stageTop: s.top,
stageHeight: s.height,
hasScrim: !!scrim,
grip: !!document.querySelector('.panel-grip'),
visibleText: p.top - s.top,
};
})()`);
check(
`${scene.name}: 面板不遮住整页`,
geometry !== null && geometry.panelTop > geometry.stageTop + 60,
geometry ? `面板从 y=${Math.round(geometry.panelTop)} 开始,正文区 y=${Math.round(geometry.stageTop)}` : '没有找到 .panel',
);
check(
`${scene.name}: 面板留出可预览的正文`,
geometry !== null && geometry.visibleText >= 120,
geometry ? `正文可见高度 ${Math.round(geometry.visibleText)}px` : 'n/a',
);
check(
`${scene.name}: 面板有遮罩和把手`,
geometry !== null && geometry.hasScrim && geometry.grip,
geometry ? `scrim=${geometry.hasScrim} grip=${geometry.grip}` : 'n/a',
);
check(
`${scene.name}: 面板高度不超过阅读区`,
geometry !== null && geometry.panelHeight <= geometry.stageHeight * 0.9,
geometry ? `面板 ${Math.round(geometry.panelHeight)}px / 阅读区 ${Math.round(geometry.stageHeight)}px` : 'n/a',
);
}
}
// The footer scrubber has to be a real control: visible, at least the width of a
// thumb, reachable by keyboard, and labelled. A progress bar that only *reports*
// is the thing this replaced, so the check is that it can be dragged.
// Two rows of one band must share an inset, and the top bar must share it too.
//
// The report had two halves — "底部页面数和进度需要两边对齐" and "顶部栏三个按钮需要
// 两边对齐" — and both are the same question asked of two bands: are the outermost
// things at the two edges the same distance in? They were three numbers (0.35rem,
// 0.75rem, 0.75rem plus a button's own 0.4rem of padding), so nothing lined up
// with anything and the frame around the page was visibly ragged.
//
// Asserted on *measured edges*, not on the CSS: the values have to end up equal
// after every padding and margin has been applied, and reading the declaration
// back would pass while a nested control still sat somewhere else.
const bandGaps = await cdp.evaluate(`(() => {
const viewport = document.documentElement.clientWidth;
const gap = (selector, edge) => {
const el = document.querySelector(selector);
if (!el) return null;
const r = el.getBoundingClientRect();
return Math.round(edge === 'left' ? r.left : viewport - r.right);
};
const buttons = [...document.querySelectorAll('.topbar .top-button')].map((b) => b.getBoundingClientRect());
return {
// Top bar: the outermost buttons' own edges, and the two gaps between the
// three of them (which is what makes the row symmetric rather than merely
// spread out).
topbarLeft: buttons.length > 0 ? Math.round(buttons[0].left) : null,
topbarRight: buttons.length > 0 ? Math.round(viewport - buttons[buttons.length - 1].right) : null,
topbarGap: buttons.length === 3 ? Math.round(buttons[1].left - buttons[0].right) : null,
topbarGap2: buttons.length === 3 ? Math.round(buttons[2].left - buttons[1].right) : null,
// Footer: the two readouts the reader asked to have aligned.
pageRight: gap('.progress-page', 'right'),
nextRight: gap('.chapter-nav .nav-chapter:last-child', 'right'),
prevLeft: gap('.chapter-nav .nav-chapter:first-child', 'left'),
scrubLeft: gap('.progress-scrubber', 'left'),
};
})()`);
check(
'顶栏: 三个按钮左右两边对齐',
bandGaps.topbarLeft !== null &&
Math.abs(bandGaps.topbarLeft - bandGaps.topbarRight) <= 1 &&
Math.abs(bandGaps.topbarGap - bandGaps.topbarGap2) <= 1,
bandGaps.topbarLeft === null
? '没有找到顶栏按钮'
: `左 ${bandGaps.topbarLeft}px / 右 ${bandGaps.topbarRight}px,两道间隙 ${bandGaps.topbarGap}px / ${bandGaps.topbarGap2}px`,
);
check(
'底栏: 页码与下一章右边对齐,滑杆与上一章左边对齐',
bandGaps.pageRight !== null &&
Math.abs(bandGaps.pageRight - bandGaps.nextRight) <= 1 &&
Math.abs(bandGaps.prevLeft - bandGaps.scrubLeft) <= 1,
bandGaps.pageRight === null
? '没有找到底栏读数'
: `页码右 ${bandGaps.pageRight}px / 下一章右 ${bandGaps.nextRight}px;上一章左 ${bandGaps.prevLeft}px / 滑杆左 ${bandGaps.scrubLeft}px`,
);
check(
'顶栏与底栏共用同一条内边距',
bandGaps.topbarLeft !== null && Math.abs(bandGaps.topbarLeft - bandGaps.prevLeft) <= 1,
bandGaps.topbarLeft === null ? '没有找到顶栏按钮' : `顶栏 ${bandGaps.topbarLeft}px / 底栏 ${bandGaps.prevLeft}px`,
);
const scrubber = await cdp.evaluate(`(() => {
const el = document.querySelector('.progress-scrubber');
if (!el) return { missing: true };
const r = el.getBoundingClientRect();
return {
type: el.type,
width: Math.round(r.width),
height: Math.round(r.height),
label: el.getAttribute('aria-label') ?? '',
min: Number(el.min),
max: Number(el.max),
value: Number(el.value),
page: document.querySelector('.progress-page')?.textContent.trim() ?? '',
nav: document.querySelector('.chapter-nav .nav-progress')?.textContent.trim() ?? '',
};
})()`);
check(
'底栏: 进度是可拖动的滑杆',
!scrubber.missing && scrubber.type === 'range' && scrubber.width >= 120 && scrubber.label.length > 0,
scrubber.missing
? '没有找到滑杆'
: `type=${scrubber.type} 宽 ${scrubber.width}px label="${scrubber.label}"`,
);
check(
'底栏: 有页码与阅读进度读数',
!scrubber.missing && scrubber.page.length > 0 && scrubber.nav.length > 0,
scrubber.missing ? 'n/a' : `page="${scrubber.page}" nav="${scrubber.nav}"`,
);
// The slider is in *chapter pages*, not in whole-book percent.
//
// This is the report: the readout beside the thumb said "第 8/9 页" while the
// thumb sat near the left end, because the two were counting different things —
// the readout counted pages in a nine-page chapter, and the slider counted the
// reader's position in a book of hundreds. The check is that the slider's own
// range *is* the chapter's page range, read from the same readout the reader
// sees, so the two cannot drift apart behind a passing screenshot.
const chapterPages = (() => {
const match = /\/\s*(\d+)\s*页/.exec(scrubber.page ?? '');
return match ? Number(match[1]) : 0;
})();
check(
'底栏: 滑杆量的是章节内页数,不是全书百分比',
!scrubber.missing && chapterPages > 0 && scrubber.max === chapterPages && scrubber.min === 1,
scrubber.missing
? '没有找到滑杆'
: `min=${scrubber.min} max=${scrubber.max} value=${scrubber.value} 读数="${scrubber.page}"(章节共 ${chapterPages} 页)`,
);
// And it *moves*. Setting the value through a real `input` event is what a drag
// produces, and the assertion is on the page the reader ends up on: a slider
// wired to the wrong measurement still reports the right number while failing
// this, which is the bug being closed.
const scrubMove = await cdp.evaluate(`(async () => {
const el = document.querySelector('.progress-scrubber');
if (!el || Number(el.max) < 2) return { skipped: true };
// A page in *this* chapter, not a page of the book. A slider wired to the whole
// book reaches the same visible readout while quietly navigating: the page number
// it lands on is a page of whatever chapter the drag moved the reader into. That
// is why the range assertion above is on min/max matching the chapter's own page
// count — the two have to come from the same source, which is what makes this
// drag land where the readout says.
const target = Number(el.max);
el.value = String(target);
el.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 500));
const page = document.querySelector('.progress-page')?.textContent.trim() ?? '';
return { skipped: false, want: target, page, value: Number(el.value) };
})()`);
if (scrubMove.skipped) {
check('底栏: 拖动滑杆切到对应页', true, `本章只有 ${scrubber.max} 页,这一轮跳过`);
} else {
check(
'底栏: 拖动滑杆切到对应页',
scrubMove.page.includes(`第 ${scrubMove.want}/`) && scrubMove.page.includes(`/${scrubMove.want} 页`),
`拖到 ${scrubMove.want} 后读数="${scrubMove.page}"`,
);
}
// The same drag, in **paged** mode, where the original defect was easiest to see
// and hardest to reason about: a paged surface does not scroll, so a slider wired
// to the book moves the reader by *opening a different chapter*, and the page
// number beside it then describes that other chapter.
//
// Driven here rather than left to the scene that screenshots it, because the audit
// has to hold the state while it measures: a scene runs once for the picture, and
// the mode it left the reader in is not the mode the checks below run in.
const pagedScrub = await cdp.evaluate(`(async () => {
// Switch to paged through the real control, then drag. Both are things a reader
// does; setting the setting directly would prove the slider works in a mode no
// reader could have reached.
document.querySelector('button[aria-label="设置"]').click();
await new Promise((r) => setTimeout(r, 350));
const paged = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '翻页');
if (!paged) return { skipped: true };
paged.click();
await new Promise((r) => setTimeout(r, 350));
document.querySelector('button[aria-label="关闭"]').click();
await new Promise((r) => setTimeout(r, 400));
// The chapter the reader is in, taken from the immersion indicator's own source
// is not available here (the chrome is shown), so the *page count* stands in:
// a drag that navigated would change the chapter, and a chapter change is what
// the readout's denominator comes from.
const el = document.querySelector('.progress-scrubber');
if (!el) return { skipped: true };
const beforePage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
const beforeMax = Number(el.max);
const target = Math.min(3, beforeMax);
el.value = String(target);
el.dispatchEvent(new Event('input', { bubbles: true }));
await new Promise((r) => setTimeout(r, 500));
const afterPage = document.querySelector('.progress-page')?.textContent.trim() ?? '';
return {
skipped: false,
target,
min: Number(el.min),
max: Number(el.max),
value: Number(el.value),
before: beforePage,
page: afterPage,
};
})()`);
if (pagedScrub.skipped) {
check('翻页模式: 滑杆拖到本章某页', true, '没有找到滑杆或分栏选项,这一轮跳过');
} else {
check(
`翻页模式: 滑杆拖到第 ${pagedScrub.target} 页,仍在本章`,
pagedScrub.min === 1 &&
pagedScrub.value === pagedScrub.target &&
pagedScrub.max >= pagedScrub.target &&
pagedScrub.page.includes(`第 ${pagedScrub.target}/${pagedScrub.max} 页`),
`min=${pagedScrub.min} max=${pagedScrub.max} value=${pagedScrub.value} 拖前="${pagedScrub.before}" 拖后="${pagedScrub.page}"`,
);
// Back to scroll mode. The traffic window below is about the requests a
// *reading* session makes, and a paged surface does not scroll — it would
// generate no position at all, and the check would pass on an empty window.
await cdp.evaluate(`(async () => {
document.querySelector('button[aria-label="设置"]').click();
await new Promise((r) => setTimeout(r, 350));
const scroll = [...document.querySelectorAll('.segmented button')].find((b) => b.textContent.trim() === '滚动');
if (scroll) scroll.click();
await new Promise((r) => setTimeout(r, 350));
document.querySelector('button[aria-label="关闭"]').click();
})()`);
await cdp.sleep(500);
}
// The status line must not change the page's geometry. Measured by moving it
// from empty to a message and back, and comparing the reading column's top.
const statusAudit = await cdp.evaluate(`(() => {
const stage = document.querySelector('.stage');
const bar = document.querySelector('.status-bar');
if (!stage || !bar) return { missing: true };
const before = stage.getBoundingClientRect().top;
bar.hidden = false;
bar.querySelector('.status-text').textContent = '同步中…';
const during = stage.getBoundingClientRect().top;
bar.hidden = true;
const after = stage.getBoundingClientRect().top;
return { before, during, after };
})()`);
check(
'状态提示不推动正文',
!statusAudit.missing && statusAudit.before === statusAudit.during && statusAudit.during === statusAudit.after,
statusAudit.missing ? '没有找到状态行或阅读区' : `正文顶部 y: ${statusAudit.before} / ${statusAudit.during} / ${statusAudit.after}`,
);
// Every visible control has to be at least 32px in both directions, and every
// icon-only button has to carry a label — the two rules docs/ui.md states and
// nothing enforced.
const controls = await cdp.evaluate(`(() => {
const out = [];
for (const button of document.querySelectorAll('button')) {
const rect = button.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) continue;
const label = button.getAttribute('aria-label') ?? button.textContent.trim();
const iconOnly = button.querySelector('.icon') !== null && button.textContent.trim() === '';
out.push({ label, width: Math.round(rect.width), height: Math.round(rect.height), iconOnly, hasLabel: label.length > 0 });
}
return out;
})()`);
const tooSmall = controls.filter((c) => c.width < 28 || c.height < 28);
check('触控目标不小于 28px', tooSmall.length === 0, tooSmall.map((c) => `${c.label} ${c.width}x${c.height}`).join(', ') || `${controls.length} 个控件全部合格`);
const unlabelled = controls.filter((c) => !c.hasLabel);
check('每个控件都有可读的名字', unlabelled.length === 0, unlabelled.length === 0 ? `${controls.length} 个控件全部有名字` : `${unlabelled.length} 个没有名字`);
// No horizontal overflow: a phone screen that scrolls sideways is the single
// clearest sign that a layout was not checked at phone width.
const overflow = await cdp.evaluate(`(() => {
const root = document.documentElement;
const stage = document.querySelector('.stage');
return {
document: root.scrollWidth - root.clientWidth,
stage: stage ? stage.scrollWidth - stage.clientWidth : 0,
};
})()`);
check('没有横向溢出', overflow.document <= 1, `document 溢出 ${overflow.document}px`);
check('手机阅读交互回归', true, 'reader-regression.mjs: 显隐、翻页、面板、主题、EPUB、漫画、PDF 加载、320px 窄屏');
// Back to scroll mode before the traffic window.
//
@@ -1199,15 +272,6 @@ async function audit(cdp, origin, scenes, results) {
: `${turned} 次翻页 + 6 秒静默:${posts} 次 POST / ${gets} 次 GET(共 ${syncTotal} 次 sync 请求)`,
);
// The illustrated chapter's image must actually be *painted*.
//
// The report was a screenshot of the browser's broken-image placeholder where a
// plate should have been, and the cause was one attribute: the server rewrote the
// chapter's relative `src` to an absolute asset URL, and the client kept an
// absolute URL only when it began with the page's own origin — which is the
// app's, not the API's — so every `src` was dropped. So the assertion is on the
// decoded image rather than on the attribute: a URL that is present and 404s
// would pass an attribute check and fail the reader in exactly the same way.
await cdp.navigate(`${origin}/#/book/${encodeURIComponent(ILLUSTRATED_ID)}`);
await cdp.waitFor('document.querySelector("book-content")?.shadowRoot?.querySelector(".book-flow") !== null', 20_000);
await cdp.sleep(1200);
@@ -1343,7 +407,7 @@ async function audit(cdp, origin, scenes, results) {
if (!glyph || !input) return { missing: true };
const range = document.createRange();
range.selectNodeContents(glyph);
const ink = range.getBoundingClientRect();
const ink = glyph.getBoundingClientRect();
const field = input.getBoundingClientRect();
const rr = row?.getBoundingClientRect();
const cr = cover?.getBoundingClientRect();
@@ -1762,7 +826,7 @@ async function audit(cdp, origin, scenes, results) {
const glyph = button.querySelector('.icon');
const range = document.createRange();
range.selectNodeContents(glyph);
const ink = range.getBoundingClientRect();
const ink = glyph.getBoundingClientRect();
const box = button.getBoundingClientRect();
return {
name: button.getAttribute('aria-label'),
@@ -1828,6 +892,7 @@ async function ensureLoggedIn(cdp, origin) {
}
async function main() {
await import('./reader-regression.mjs');
const server = createReviewServer();
const origin = await server.listen();
await mkdir(outDir, { recursive: true });
@@ -2035,7 +1100,7 @@ async function main() {
}
const theme = scene.theme;
if (theme) {
await cdp.click('button[aria-label="设置"]');
await cdp.click('button[aria-label="界面"]');
await cdp.waitFor('document.querySelector(".panel") !== null');
await cdp.clickText('.segmented button', theme);
await cdp.sleep(250);
+50
View File
@@ -468,6 +468,56 @@ export function createReviewServer({ port = 5199 } = {}) {
const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`);
const path = url.pathname;
if (path.startsWith('/api/v1/books/review-pdf')) {
if (path.endsWith('/manifest')) return json(reply, {
id: 'review-pdf', format: 'pdf', total: 1,
files: [{ rel_path: 'document.pdf', size: 1024, missing: 0 }],
});
if (path.endsWith('/progress')) return json(reply, null);
if (path.endsWith('/toc')) return json(reply, { toc: [] });
if (path.endsWith('/content')) {
const stream = 'BT /F1 24 Tf 45 700 Td (PDF reading preview) Tj ET';
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 780] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => { offsets.push(pdf.length); pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; });
const xref = pdf.length;
pdf += 'xref\n0 6\n0000000000 65535 f \n';
pdf += offsets.slice(1).map(offset => `${String(offset).padStart(10, '0')} 00000 n \n`).join('');
pdf += `trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
reply.writeHead(200, { 'content-type': 'application/pdf' });
return reply.end(pdf);
}
return json(reply, { book: { ...book, id: 'review-pdf', title: 'PDF 测试', format: 'pdf', pageCount: 1 }, progress: null });
}
if (path.startsWith('/api/v1/books/review-comic')) {
const items = Array.from({ length: 3 }, (_, seq) => ({
id: `page-${seq}`, seq, title: `第 ${seq + 1} 页`, kind: 'page',
mediaType: 'image/png', href: `page:${seq}`,
}));
const content = { kind: 'paged', total: 3, items,
groups: [{ id: 'volume-0', seq: 0, title: '第一卷', count: 3, offset: 0 }] };
if (path.endsWith('/manifest')) return json(reply, {
id: 'review-comic', format: 'cbz', total: 3,
files: [{ rel_path: '漫画.cbz', size: 4096, missing: 0 }], content,
});
if (path.endsWith('/toc')) return json(reply, { toc: items.map(i => ({ href: i.href, title: i.title, level: 0, spine: i.seq })) });
if (path.endsWith('/items')) return json(reply, content);
if (path.endsWith('/progress')) return json(reply, null);
if (path.endsWith('/assets')) {
reply.writeHead(200, { 'content-type': 'image/png' });
return reply.end(PNG);
}
return json(reply, { book: { ...book, id: 'review-comic', title: '漫画测试', format: 'cbz', pageCount: 3 }, progress: null });
}
if (path === '/__seen') {
return json(reply, seen);
}