diff --git a/.gitignore b/.gitignore index 9ed0704..67c0380 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index e1537c0..645cc03 100644 --- a/README.md +++ b/README.md @@ -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`。 ### 翻页与章节导航 diff --git a/docs/ui-review/README.md b/docs/ui-review/README.md index 8585cc7..8aaba56 100644 --- a/docs/ui-review/README.md +++ b/docs/ui-review/README.md @@ -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 | 全部通过。 diff --git a/docs/ui-review/report.json b/docs/ui-review/report.json index e465df6..80025b6 100644 --- a/docs/ui-review/report.json +++ b/docs/ui-review/report.json @@ -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": [] diff --git a/docs/ui.md b/docs/ui.md index 6f6f43a..c28a173 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -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`)标出, - 它是一行五个控件里唯一一个破坏性的。 - -播放器元素本身(`