Commit Graph
46 Commits
Author SHA1 Message Date
hectorqin 1663773f4d feat(web): adapt collections and reading controls for desktop 2026-09-21 17:01:19 +08:00
hectorqin 4a1aa7a4d0 fix(web): remove shelf settings sticky header gap 2026-09-21 15:45:01 +08:00
hectorqin 3420bf07ce fix(library): 限定管理员上传并完善文件管理导航与布局 2026-09-21 15:25:28 +08:00
hectorqin 854bfff1ce fix(ui): 统一书架书库入口与空状态层级 2026-09-21 14:25:05 +08:00
hectorqin 3a253c1c1a fix(sources): 使用配置弹窗并拆分搜书与书源管理 2026-09-21 14:03:35 +08:00
hectorqin d8cb7cc84c fix(sources): 明确顶部 Tab 并收起次要管理操作 2026-09-21 13:48:45 +08:00
hectorqin 8e8ca23d3b fix(sources): 完善配置交互并增加真实端到端测试 2026-09-21 13:03:30 +08:00
hectorqin c27c505f24 feat(sources): 按书源实例隔离插件配置并拆分管理 Tab 2026-09-21 12:28:50 +08:00
hectorqin 964a666f5b feat(plugins): 支持独立配置页、多书源订阅与目录换源 2026-09-21 11:50:21 +08:00
hectorqin 59faec336a merge: 同步远程阅读界面更新并保留书源配置样式 2026-09-21 09:39:39 +08:00
hectorqin c192fc2f12 feat(sources): add configuration pages and chapter updates 2026-09-20 23:59:12 +08:00
hectorqin d60d7e67f9 feat(reader): 配置四角阅读信息 2026-09-20 17:36:59 +08:00
hectorqin da1aa15174 fix(reader): 稳定阅读位置并重整手机阅读界面
固定工具栏显隐时的正文区域,保存最新阅读偏移并抑制亚像素量测抖动。重组底部四入口、半屏面板和章内滑杆,采用 Lucide SVG 图标并新增浅绿主题;补齐 PDF 内容传递。

验证:构建通过,498 项单元测试、29 项接口及分页测试、30 项 UI 检查通过。
2026-09-20 17:02:40 +08:00
hectorqin 847f046724 合并来自 auto/reader-indicator-avoid-text-b5b4 的合并请求 #53
fix(reader): 读数行不再压住正文,翻页落在整行上

PR-URL: #53
2026-09-20 15:21:27 +08:00
hectorqin ab22d2c3c6 fix(reader): 读数行不再压住正文,翻页落在整行上 2026-09-20 12:15:36 +08:00
hectorqin ae397b6181 feat(web,server): 补全书架的加入/移除两侧并统一可见性判据 2026-09-20 11:52:15 +08:00
hectorqin 2afb137d37 合并来自 auto/reader-page-turn-indicator-0add 的合并请求 #51
fix(reader): 翻页不再丢滚动位置,阅读指示占满整行

PR-URL: #51
2026-09-20 10:48:59 +08:00
hectorqin 80382c078d fix(reader): 翻页不再丢滚动位置,阅读指示占满整行 2026-09-20 10:37:27 +08:00
hectorqin 204b6c6388 修改样式 2026-09-20 09:54:08 +08:00
hectorqin 86c2d8631e Merge remote-tracking branch 'origin/main' into auto/library-browse-files-split-2e69
把 #48 的朗读条与快捷列改动合进本分支,两处冲突都在
`web/src/ui/reader-chrome.tsx`,都取 main 那一侧:

- 快捷列:main 删掉了 目录 / 行距 / 页边距 / 设置 四个入口(#48 的报告),
  本分支新增的 `menu` / `line-height` / `indent` / `sliders` 随之作废。
  留下 主题 / 字号 / 听书 三项,字号改用本分支新词汇表的 `text-size`,
  听书改用 `volume`。
- 朗读条:main 拆成两行(控制行 + 进度行)并加了 `tts-stop`。本分支的单行
  版本丢弃。图标名按本分支的词汇表回落:`backward-step`/`forward-step`
  → `step-backward`/`step-forward`,`xmark` → `close`。

为让两边都能编译,`icon-names.ts` 增加一层别名表 `ICON_ALIASES`
(`font`/`volume-high`/`bars`/`gear`/`xmark`/`magnifying-glass`/`books`
→ 已画的码点),另导出 `ICON_CODEPOINT_TABLE`。别名是**拼写**不是字形:
`icons.test.ts` 的借用名单断言只管 `paths.mjs`,新增两条断言钉住别名既
落在被否名单里、又必须指向字体真画了的码点;码点唯一性改按去重后的集合计。
2026-09-20 09:34:58 +08:00
hectorqin 7f4cc477c4 feat(web): 书库浏览页与文件管理拆成两个屏幕,图标改自绘 2026-09-19 23:24:34 +08:00
hectorqin 09a7d4df5d fix(web): 书内目录链接当章节跳转,朗读条可停止,页面铺满且工具栏浮动 2026-09-19 23:04:03 +08:00
hectorqin 9ad0e64a4c 合并来自 auto/shelf-library-sort-icons-cc2f 的合并请求 #46
feat(web,server): 书架默认按最近阅读排,书库拆成预览与文件两页

PR-URL: #46
2026-09-19 22:24:58 +08:00
hectorqin 7f74baab68 fix(web): 插图收进版心,快捷列补齐并可滚动,点击区按布局几何 2026-09-19 14:46:15 +08:00
hectorqin 558f44e206 feat(web,server): 书架默认按最近阅读排,书库拆成预览与文件两页 2026-09-19 14:17:55 +08:00
hectorqin 4715704d84 合并来自 auto/reader-floating-chrome-de96 的合并请求 #45
fix(web): 工具栏改为浮层,并给章节子资源带上令牌

PR-URL: #45
2026-09-19 13:23:24 +08:00
hectorqin 354e7eeb6d fix(web): 工具栏改为浮层,并给章节子资源带上令牌 2026-09-19 12:31:51 +08:00
hectorqin f2701da703 fix(web,server): 继续阅读带上完整书对象,书库能上架,放大镜回到中线
三处 #40 后续:继续阅读卡片点了报「不在书架上」、书库的书加不回书架、
书架搜索框的放大镜高了一行。
2026-09-19 11:46:42 +08:00
hectorqin 69993d7b37 Merge remote-tracking branch 'origin/main' into auto/reader-paragraphs-layout-images-eb50 2026-09-19 09:13:54 +08:00
hectorqin 0551f21e4e fix(web,server): 段落不再合成一块,插图重新画出来,工具栏对齐 2026-09-19 09:09:12 +08:00
hectorqin d489c45cf3 Merge remote-tracking branch 'origin/main' into auto/shelf-library-pagination-d4fc 2026-09-19 08:49:21 +08:00
hectorqin c7d065bd52 feat(web): 书架与书库拆成两个可分页的屏幕 2026-09-19 08:45:37 +08:00
hectorqin b8fda56c34 fix(web,server): 底部滑杆量章节内页数,快捷列随工具栏收放,chapter-full 改名 2026-09-19 08:17:46 +08:00
hectorqin 29f7f6b56a feat(web): 顶栏底栏按参照稿重做,沉浸态保留章节与页码
现状问题。上一轮把顶栏和底栏做成"一起收放"之后,两个方向都还差一截。显示时
顶栏是"返回箭头 + 书名 + 一个 ☰",底栏是一条 3px 的进度条加五行图标;参照稿
要的是顶栏一排等重的"图标在上、文字在下"按钮、底栏一行可拖动的滑杆加一行
"上一章 / 阅读进度 / 下一章"。收起时屏幕上什么都不剩:读者收起工具栏是为了
看正文,不是为了不知道自己读到哪儿。

方案依据。顶栏改成 `.top-button`——图标在上、名称在下的等宽按钮,`space-evenly`
排布,按钮只保留这个屏幕真能做到的返回 / 目录 / 设置(参照稿里"首页/书架/书源"
含阅读页内部走不到的目的地,一个点了没处的按钮比少一个按钮更糟)。底栏第一行
换成 `input[type=range]`:滑杆一个手势就能到章末,而进度条只能报告位置,读者
想到一章末尾得连点四十次;原生控件的拇指、拖动惯性、方向键和 `aria-valuenow`
都白得。第二行是两端的上一章/下一章和中间的进度文字。收起态新增
`.reading-indicator`:章节名钉在左上、页码与总进度钉在左下,两个读数
`pointer-events: none`,让点它们的手势落到下面的翻页区,且只在收起时绘制。

放弃方案。把书名留在顶栏:读者找标题时眼睛去的是正文角上,不是工具栏,而且
标题随工具栏一起消失正是当初要一个常驻读数存在的原因。收起态只留侧边快捷列:
那解决了"能操作",没解决"知道在哪",而后者是沉浸阅读里更常被问到的那个。

顺带修掉一个真 bug:`chevron-right` 的字形路径与 `chevron-left` 逐字节相同,
所以"下一章"按钮一直指向左边。改路径、重生成字体,并补两条断言——同名字形
不得共用路径、两个 chevron 必须镜像——它们此前一条都没有,所以这个 bug 能在
所有测试全绿时存在。

改动点。`reader-chrome.tsx` 新增 `TopButton`、重写顶栏与底栏、新增
`.reading-indicator` 与 `onScrub` 通道;`reader-screen.tsx` 新增 `scrubTo`;
`reader.css` 重写顶栏/底栏/状态药丸定位,新增滑杆与阅读指示样式;`paths.mjs`
修 `chevron-right`;`docs/ui.md` §3.8 补顶栏、底栏滑杆、阅读指示三节;ui:review
新增"保留章节与页码指示 / 阅读指示不吃手势 / 进度是可拖动的滑杆 / 有页码与
阅读进度读数"四条断言。

验证方式。`cd web && npm test`(371 条)、`cd server && npm test`(205 条)
全绿;`npm run build` 通过;`npm run ui:review` 全绿,16 条尺寸断言全过,截图
见 `docs/ui-review/`。曾把 `chevron-right` 的两条新断言逐条还原验证:路径改回
旧值时两条都红。

Ref: #34
2026-09-18 22:48:03 +08:00
hectorqin 7b9c786c83 feat(web): 纯文本排版改在客户端,并让顶栏底栏一起收放
现状问题。TXT 的段落和缩进原本由服务端决定:`chapter-html:<n>` 把章节渲染成
带 `<p>` 的片段,客户端只是把它塞进 shadow root。于是三件事同时不对。一是缩进
设定只对服务端已经渲染好的那份生效,windowed 路径拿到的 `chapter:<n>` 是纯字符
没有段落,同一个读者同一本书会因为走哪条传输而长得不一样。二是抓取来的文件里
用来当缩进的段前空格被原样留下,和读者的缩进叠加,恰好在最需要这个设定的文件
上看起来像没生效。三是这些设定是每台设备各自的阅读偏好,烧进响应就等于把每次
调整变成一次往返。另一件事是顶栏和底栏可以各自隐藏,读者收起导航后底栏仍压着
每一页的最后两行。

方案依据。把「一段是什么」收敛到客户端的唯一定义(`formats/segments.ts`),
本地加载、windowed、流式三条路都走它,服务端的职责缩小为交出完整章节和在原文
上断段。`ReaderView` 在渲染前对纯文本章节再过一遍这个拆分(按块读回、拼回空行
再拆,所以服务端断得对不对不影响观感),缩进、段间距、去掉段前空格因此无需请求
即可作用于两条路径。顶栏与前底栏由 `.reader-screen` 上的一个 `data-chrome` 统一
开关,收起时两条栏一起归零高度并滑出、正文长满整屏,同时右侧浮出一列快捷按钮,
否则收起工具栏会变成「必须离开才能操作」的状态。

放弃方案。把服务端的 `chapter-html` 直接删掉:`href` 同时是阅读位置的标识,且
`format: 'html'` 字段已是既有契约,删掉会让旧客户端与旧位置一起失效,所以保留
作为传输,只去掉它承担的排版职责。让缩进靠服务端属性(`data-indent`)继续驱动:
那正是「设定是响应的属性」这一问题的根源。收起态用两个独立开关:写法上就能只藏
一半,而这恰是读者报的那个 bug。

改动点。新增 `web/src/formats/segments.ts`(分段、去段前空格、HTML/节点两种形
式、跨 chunk 的 carry 判定);`reader-view.ts` 增加 `retypePlainText` 并去掉旧
排版表里读服务端属性的规则;`render/text.ts` 按 `carry` 拼接跨块段落;服务端
`text-html.ts` 去掉 `indent` 选项、不再截断;`reader-chrome.tsx` 增加侧边快捷列
与设置面板顶部的「快捷」一行;`reader.css` 增加两条栏的收放与按钮列样式;图标
集新增 `text-indent` / `text-type` / `spacing`;`docs/ui.md` 补 §3.7 的边界说明
与 §3.8 沉浸式阅读。

验证方式。`cd web && npm test`(369 条)、`cd server && npm test`(205 条)全绿;
`npm run build` 通过;`npm run ui:review` 全绿,并新增「收起工具栏: 顶栏与底栏一
起走 / 都不|不可见 / 正文占满整屏 / 右侧仍有快捷按钮」四条尺寸断言(截图见
`docs/ui-review/03-reader-no-chrome.png`),评审夹具改用全角空格缩进,使「段前
空格被去掉」在截图上可见。

Ref: #34
2026-09-18 19:09:51 +08:00
hectorqin cdc95d8d27 fix(web,server): 阅读页可读性与面板、翻页、同步频率、事件层级
用户报的八条里,这一提交覆盖了除图标对齐之外的七条。每一条都先做成能复现的
测试或量出来的数字,再改。

## 2. txt 没有处理就渲染,没有默认样式,也无法调整

三层都缺,所以修了三层。

服务端新增 `chapter-html:<n>`,把纯文本渲染成带 p 的片段
(`indexer/formats/text-html.ts`)。空行断段;「上一行以句末标点结尾、下一行以
句子开头」也断段——抓取来的网文常常每行一段且没有空行,少了这条整本书是一块。
章节标题行提升成 h3,否则正文会变成「第二章 落雨雨来了。」。引用仍是
`chapter:<n>`(它同时是阅读位置的标识,改它会把现有读者打回第一章),新增
`format` 字段说明取哪一份;`chapter:` 的 256KB 截断也不再作用于这份渲染结果——
那个截断会把长章节切一半。

客户端新增 `TXT_STYLESHEET`,只在 `format === 'txt'` 时注入 shadow root,作用域
`.txt-body`,所以 EPUB 作者自己的样式一点不受影响。默认两字符首行缩进,用
`2em` 而不是 `2rem`:缩进要跟字号缩放。

阅读设置新增「正文」一节(段落缩进 / 段间距 / 编码),只在 TXT 上出现,前两项
是写在 stage 上的自定义属性,所以调整不需要重新注入章节。

## 3. 翻页异常,往前翻页还会来回跳

`previous()` 用 `scrollTop > 8` 判断「还有没有地方可退」,`next()` 用
`scrollTop < limit - 8` 判断「还有没有地方可进」,然后两边都以 0.9 屏步进。
这三个数字互不相容:forward 落到的位置,back 不认为那是边界。读者看到的就是
来回跳。

改成按**页序号**算术:整屏步进,页边界由滚动**范围**(`scrollHeight -
clientHeight`)而不是内容高度推出。这一个区别是页数和可翻次数对不上的原因——
2.31 屏的一章,按内容高度算 3 页并请求 offset 0/723/1446/2169,而 2169 会被
夹到 1674,也就是第 2 个边界,于是第四次按下的位置计数器不认。

`stepSection` 改成 await:它在跨章时是 fire-and-forget,而 `turnPage` 在
`previous()` 返回后就播放动画——动画演的是读者刚离开的那一章。

## 4. sync 接口会不停的重复渲染,sync 接口调用太过频繁

两处。`onPosition` 每个位置调三次 `patch()`,而 `patch` 会重绘整棵 chrome
树,滚动时每帧三次;合并成一次。

`flushProgress` 无条件 `syncNow()`,于是 1.5 秒一轮 POST+GET,读者只要在读就
一直发。`SyncEngine.schedule()` 去抖并**保证尾次**——没有尾次的话,读者翻完
最后一页就关书,那一页永远发不出去。

## 5/6. 布局样式,以及目录/设置改半屏

面板从 `inset: 0` 改成底部半屏 + 遮罩 + 把手,上半屏正文仍然看得见:选章节、
调字号都是对下面那一页的调整,全屏让读者为了做调整而离开那一页。

状态行从 flex 列里的一行改成浮在正文上的药丸。横条每出现一次就把正文推下去
一行再收回来,也就是把读者正在读的那句挪走。`ui:review` 有一条断言盯着它。

页脚改成两行不同职责:控制行居中、读数是三个固定位置的等宽数字。目录行改成
「序号 + 标题」,当前章高亮一次而不是每行都划线;当前窗口到不了的行标记出来
而不是静静地没反应。

## 7. 点击页面的事件处理层次

一次触摸只属于一个目标,最里面那个赢。两个真缺陷:

- 必须用 `composedPath()` 而不是 `event.target`。书的正文在 shadow root 里,
  shadow 边界会**重定向**事件,所以 target 是 shadow host——用 target 写不出
  「段落」和「同一个 shadow 树里的按钮」的区别。
- **阅读面自己是个滚动容器**。滚动模式就是让 shadow host 滚起来,于是「可滚动
  的祖先拿走手势」这条规则让它吃掉了每一个手势:翻页层级完全正确,但永远
  到不了。这是「点击页面没反应」的真正原因。

另外,按到控件的触摸不再被吞掉 click——之前工具栏按钮可见、可点、没反应。

面板打开时它拥有点击:旁边露出的那一条正文不翻页。

## 8. 端到端测试要做 UI 评审

新增 `web/tools/ui-review/`:真实 Chromium(390×844、2x)加载**生产产物**,
对着替身 API 逐屏截图并测量。截图给人看(「只是丑」不是测试失败,但是缺陷),
尺寸给流水线断言(面板不遮住整页、留出 ≥120px 正文、有遮罩和把手、状态提示
不推动正文、控件 ≥28px 且有名字、没有横向溢出)。接进 `client-ui-review`
流水线,只在界面相关文件变化时触发。

这套工具第一次跑就发现了四个真缺陷:`api.toc()` 返回形状不符时 `openStaged`
用 TypeError 打死整个 open(现在回退到 manifest 自己的条目);状态行一旦进入
loading 就再也清不掉(`hideStatus` 的守卫挡住了唯一的出口);翻页步进的 0.9 屏;
以及上面那条阅读面吃掉手势。

## 测试

web 351 项、server 206 项全过。新增:`gestures.test.ts`(事件层级优先级)、
`reader-paging.test.ts`(页序号算术,含子像素取整与滚动范围)、
`mount.test.ts`(单次渲染与卸载后丢弃更新)、`text-html.test.ts`(分段、
转义、标题提升、不截断)。
2026-09-18 12:39:45 +08:00
hectorqin fd743f3a3f fix(web): 修好图标与文字的对齐,并让字体度量不再说谎
界面图标显示正常但歪,实测每个字形都偏离所在盒子中心 0.496em(半个盒子),
也就是 9px 以上,且每一行歪的幅度还不一样。代价是三条互相独立的缺陷叠在
一起,而此前所有图标测试都只读 `cmap` 和字形名,一条都不覆盖这些位置。

一、`.icon` 把字体自己的行盒换成了一个方块(主因)

`line-height: 0; display: inline-block; width: 1em; height: 1em` 会得到
一个**锚在基线上、向下一整个 em** 的盒子,而字形是围绕基线画的。于是墨迹贴在
盒子上沿、盒子下方整片是空的。UI 里每个容器都在居中这个盒子,盒子确实居中了,
字形却差了半个 em——这就是「显示正常但不对齐」,也是每行幅度不同的来源:各行的
容器不同,锚法就不同。

改为用字体自己的行盒(`line-height: 1`,不再给高度),只保留 `width: 1em`
这个字体给不了的东西(可预测的前进宽度),并补 `vertical-align: middle` 给
正文里行内出现的图标。**刻意不用 `transform`**:它能在 flex/grid 里生效
(`vertical-align` 在那里是惰性的),但它改的是绘制期位置,会让「图标占的位置」
和「看起来占的位置」不再一致。

二、字体度量与字形不自洽(次因)

表格声明上升部 960 / 下降部 -64(基线靠近 em 底部),而字形是按
`(GRID/2 - y)` 围绕 em 中心画的。两者各自自洽、互相矛盾,而输的是盒子。
现在上升部与下降部各取半个 em:**对称**不是审美——UI 里每个容器都在居中盒子,
只有度量对称时「居中盒子」和「居中字形」才是一回事。

同时修掉三处字段错位(都会静默通过解析):

- `OS/2` 的 `sTypoAscender/Descender` 一直写在 panose 区域,真正的排印窗口是 0/0;
- `usWinAscent/Descent`(**无符号**)也写错了位置,读出来是 0 / 65506;
- `fsSelection` 原本声明 BOLD,`usWeightClass` 是 500——一个不粗的字体谎称自己粗,
  引擎就会合成加粗,而合成出来的图标是糊的。改为 `REGULAR | USE_TYPO_METRICS`。

`head` 的每个字形包围盒补一格下限:`more` 的三个点是用 0.06 单位描边写的,
四舍五入后两个轴各只剩一个点,包围盒为空——空包围盒是栅格化器有权跳过的图形,
于是「更多」按钮画出来是白的,而 `cmap` 依然映射它的码位,所以读表的断言全都
满意。`settings` 的路径解析出了一组跑到网格外的坐标(y 到 24.8、x 到 0.8),
重画成真正的齿轮;`chevron-right` 与 `chevron-left` 共用同一个框却差一个单位。

三、图标按钮有两条构建路径

`manager-more` 是手搭的 `<button class="icon-button">`,而 `.icon-button` 的
对齐靠的是「里面有 `.icon`」这个约定——手搭的那条路径绕过了它。现在一律走
`IconButton`,`label` 必填、字形是名字而不是任意子节点;图标加文字的新组合收进
`IconTextButton`,因为那正是最容易歪、且每次都歪得一样的组合。

验证:新增 10 条断言,覆盖字体度量、字形包围盒与 `.icon` 规则本身;把改动逐条
还原后它们会红(已验证)。真浏览器验收:37 个字形逐个测量,墨迹中心与盒子中心
最大偏差 0.88px(改前是 9px+);书架与阅读器两屏在真实产物上截图核对。客户端
`typecheck` / 317 个测试 / `build`、`check-shell.sh`、`verify-web-assets.sh` 全过。
产物 259.68KB → 259.68KB,字体 16.34KB,CSS 24.54KB。

Ref: #34
2026-09-18 11:30:36 +08:00
hectorqin 3c3e461a49 feat(web): 界面图标换成自带字体,补 UI/UX 规范并统一设计令牌
按钮里放的是 Unicode 字符——`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁` `⮝`——
和 emoji。八个字形来自八个字族,光学重量各不相同;`🗂` `📁` `⏮` 在部分平台
被渲染成彩色的,而一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的
信号。不同平台的字体回退还不同,同一个界面在 Android 和桌面浏览器上长得不一样。
此外样式表里所有按钮都没有 `:focus-visible` 规则,键盘走一遍看不到焦点在哪。

所以这次做三件事:图标改成自带字体、把缺失的交互状态补齐、把散落的字面量收成
设计令牌,并把规范写成文档。

图标用字体而不是内联 SVG,理由是**一致性可以被强制**:字体把字形变成字符,它
继承 `color` 和 `font-size`,没有自己的几何,因此没有可以漂移的地方;而三十个
内联 SVG 组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的位置。字体是
bundle 里的一个 16KB 相对路径文件,`file://` 下能加载,与「一份产物两个宿主」
的既有约束一致。

字形由 `web/tools/icons/` 下的脚本从描边路径编译:路径 → 折线 → 圆头矩形 →
精确布尔并(Clipper)→ TTF。先做过一版「栅格化 + 边界追踪」,失败了:笔画宽度
只有一个栅格单元,而逐单元的边界规则无法表达「边界在一格宽的图形上左右交替」,
结果是一个圆被追踪成 58 段碎片。精确布尔没有这种情形。

放弃的方案:内联 SVG(前面说的漂移问题)、把图标做成 React 组件并逐个手绘
(每个都是独立的几何,光学重量必然不一致)、从 CDN 引用图标字体(本产品必须
在无外网环境下工作)。

界面改动:
- `Icon` 组件 + 37 个字形,码位表由生成器写出,测试直接解析字体 `cmap` 核对
- `IconButton` 的 `icon` 是字形名而非任意 `children`,传错名字是编译错误;
  `label` 必填,字形始终 `aria-hidden`
- 补上全部按钮的 `:focus-visible`(本次之前完全缺失)
- 间距/字号/动效收成令牌;`prefers-reduced-motion` 通过折叠时长令牌实现,
  而不是给每条动画各加一个媒体查询
- 悬停包进 `@media (hover: hover) and (pointer: fine)`,避免触屏上按钮停在悬停态

验证:`web` 336 个测试通过(新增 9 个图标契约测试);`typecheck`、`build`、
`check-shell.sh`、`verify-web-assets.sh` 全过;字体已确认被打进 Android assets。
真浏览器(Chromium + 真实服务端)实测了书架、书库管理、阅读器三个屏幕的浅色与
深色主题,以及键盘 Tab 下的焦点环。产物 259.5KB → 259.5KB(gzip 81.4KB),
新增 16KB 字体、CSS 减到 24.4KB。

Ref: #29
2026-09-18 07:08:02 +08:00
hectorqin ecb2ef4806 fix(web): 修复阅读页章节跳转失灵,补齐上一章/下一章 2026-09-17 23:52:11 +08:00
cnb.dQC5J6ohAGA dbaac99b45 feat(web): 界面加路由,首页重做
首页(书架)此前是纯命令式拼出来的,而整个客户端没有任何路由:URL 从头到尾不变,
返回键、刷新、分享、收藏一概不成立。

## 路由

`ui/router.ts` 把 fragment 解析成 route,shell 渲染 router 报上来的那个:

  #/shelf             书架
  #/book/<id>         阅读器
  #/library/<路径>    书库里的目录

用 fragment 而不是路径,是因为同一份产物要被两个宿主加载,而两个都服务不了任意路径:
Android 从 appassets.androidplatform.net 加载(那里没有服务端),H5 由 reader 服务端
托管(没有客户端路由回落)。路径式路由会在 vite dev 里正常、在两个生产宿主里 404。

返回走应用自己的屏幕轨迹,不走 history.back():应用里的「返回」是「上一个屏幕」,
而浏览器历史里可能有登录表单或别的站点。轨迹为空时(新标签页里粘贴的链接)替换成父级,
而不是压入——否则那个链接会永远压在书架后面。

走进书库子目录用 replace:书库是一个带面包屑的屏幕,走目录是同一个屏幕改自己的参数。
压栈的话,读者走过四个文件夹再按返回会逐层退出来。

带着链接进来而尚未登录时,route 存在 App.pending 里等在登录页后面,登录后直接落到
目标屏幕,而不是落到一个他从未见过的书架。

Android 外壳的返回手势不需要改动:它看的是 WebView 的 canGoBack(),而路由现在真的
往 WebView 历史里写——之前那段注释描述的行为,从这次起才成立。

## 首页

- 先看到书,再看到控件:标题和封面在最上面,搜索在后,排序与数量贴在它们作用的那一节上。
  旧版把搜索框、数量、四个排序 chip 依次排在封面之前,三行控件才轮到第一本书。
- 排序栏是唯一吸顶的东西,它承载的是读者会滚回去改的两件事。
- 空状态给一条出路(打开书库管理 / 刷新 / 清除搜索),而不是一句「书库还是空的」。
- 冷启动画骨架屏,且按当前密度画——密度不对的骨架屏会让读者看到布局动两次。
- 封面缺图时把书名画成封面,而不是一个灰色方块。

## 顺手修掉的 5 个真缺陷

1. mountUI 在 unmount 之后仍会渲染。屏幕是异步加载的,离开后那条迟到的响应会把
   整棵树画进一个刚被清空的容器;书架迟到的响应会把自己一屏封面画进书库管理器。
2. --safe-left/-right/-top/-bottom 从未在生效的样式表里定义过,只有一份没人 import 的
   死样式表定义过。未定义的自定义属性会让整个 calc() 失效并静默丢弃声明。
3. 封面在 flex 列里没有显式宽度,aspect-ratio 盒子因此收缩到内容宽度:只有书名的封面
   塌成 42px、有图的占满卡片,整排看起来参差不齐。
4. 阅读列在宽屏上被拉伸到 9 列 45px 的封面。现在内容列封顶 68rem,封面随断点变大。
5. 删掉 web/src/ui/styles.css——414 行从未被引用的旧样式,以及 .book-highlight 等
   已经没有任何代码产出的规则。

## 验证

- web 全量 284 项测试通过(新增 router 19 项 + 链接契约 4 项)。
- typecheck / build / check-shell.sh / build-web-assets.sh 均通过。
- 真浏览器实测:深链直开、刷新不丢、返回离开屏幕、登录后落回目标、书库走目录、
  空搜索、粘性工具栏、深浅色、390px 与 1440px 两档宽度。
- 体积:251KB → 252KB(gzip 79.1KB)。
2026-09-17 18:49:53 +08:00
CodeBuddy 586bec25b1 refactor(web): 界面层迁移到 Preact,排版层保持命令式
书架、登录、书库管理、以及阅读器的铬层(顶栏/页脚/目录/设置面板/朗读条)
从「构造函数建节点 + 实例字段 + 若干 render*()」改成组件树 + 单一状态。
阅读舞台——分页、注入文档、shadow root、量测列宽——仍然零框架。

为什么换:
- 同一份状态有多个副本。分段控件的选中态既在 settings 又在 aria-pressed 上,
  靠点击处理器手工同步;LoginScreen 的通知/错误/注册开关/按钮文案是四个节点
  被五个地方改,漏一处不报错。
- 重建与状态互相矛盾。朗读引擎行要在 HTTP 能力探测回来时重新生成,而整块
  面板重建会丢掉面板滚动位置,于是长出一套「只替换 [data-speech=group]」
  的定点重建;改成组件后探测只是改状态,diff 天然保住滚动位置。

边界(写在 ReaderScreen 里,不靠自觉):
- stage 由类持有并交给 ReaderView,只通过 ref 回调挂进组件树;组件决定它
  放在哪,内容一概不碰。mountUI() 是组件树与类式屏幕之间唯一的接缝,
  路由仍不认识框架,测试仍打在真实 DOM 上。

配套:
- toolkit.tsx:IconButton / Button / Segmented / SwitchRow / Scrim 这些原语
  各把 a11y 与状态细节收在一处,消掉两处手工 aria-pressed 同步。
- vendor/preact.ts:框架唯一入口,只 re-export preact 与 preact/hooks,
  无 preact/compat、无 react-dom、无额外 signals 依赖。
- tsconfig: jsx=react-jsx, jsxImportSource=preact。
- 修一处回归:书库管理的状态行改由 listing 推导,写入结果跨重载保留。

验证:web 全量 260 项测试通过(manager.test.ts 断言逻辑未改),
typecheck、build、android check-shell.sh、build-web-assets.sh 均通过。
产物 235KB → 245KB(gzip 71.2KB → 77.2KB)。
2026-09-17 17:52:18 +08:00
cnb.dQC5J6ohAGA b7ffa42c80 feat(library): 支持上传书籍与批量管理 2026-09-17 16:50:47 +08:00
cnb.dQC5J6ohAGA 282e0426c2 feat(library): 新增书库管理页面 2026-09-17 12:54:52 +08:00
cnb.dQC5J6ohAGA 0370cd98e8 feat: HTTP TTS 与双端兼容朗读,书架体验与自定义设置
朗读(Closes #13 的朗读部分)

新增服务端 HTTP TTS 代理与客户端第二、第三个朗读引擎,三者在同一个
SpeechEngine 接口下可切换:

- 服务端 GET /api/v1/tts 代理 TTS_URL 指向的合成服务(edge-tts / Piper
  等兼容形态),只代理不内置合成模型;GET /api/v1/tts/voices 回答
  「这个实例能用什么」,未配置时返回 http:false 而不是 404。
  按句合成、磁盘缓存、上游返回非 audio/* 时明确报错(一个指到网页的
  TTS_URL 会以 200 返回 HTML,塞进 audio 里既没声音也没事件)。
  /tts 是精确的查询串令牌白名单项之一:<audio src> 无法带 Authorization。
- 客户端 HttpTtsEngine 用 <audio> 而不是 Web Audio,事件驱动推进队列。
- 客户端 NativeTtsEngine 走 Android 外壳新增的 speech 接口(shell v3):
  WebView 自带的合成器与系统的是两个引擎,手机系统装好的中文语音经常
  不在 getVoices() 里,这是「朗读没有中文声音」最常见的原因。
- speechAvailability 决定 auto 用哪个引擎:native → system → http。
- 原 TtsEngine 抽出 loadQueue/onChunk/onState 为公开字段以结构化满足接口。

书架

- 排序:最近更新 / 最近入库 / 书名 / 作者,中文按数字大小排。
  排序规则抽到 shelf-order.ts,离线路径与服务端用同一条规则。
- 密度、作者显隐、封面进度条;搜索一键清空 + 回车即搜;下拉刷新。
- 服务端 BookDto 增加 addedAt,否则客户端离线排序无法复现服务端结果。

自定义设置

- 朗读引擎可选(引擎多于一个时才显示该行);音调行只对有声调的引擎出现。
- 书架设置独立成 sheet,与阅读面板分开。

测试与文档

- server: tts.test.ts(10 例)。web: tts-http / speech / shelf-order
  新增 39 例,合计 245 passing。
- android/scripts/check-shell.sh 修正为同时校验嵌套的 speech 接口,
  之前它只能看到一个顶层接口。
- README / docs/api.md / docs/architecture.md 补齐三个引擎的取舍、
  环境变量与错误码。

Ref: #13
2026-09-16 23:29:41 +08:00
cnb.dQC5J6ohAGA 058bee57a5 feat(web): 丰富阅读界面设置并支持系统 TTS 朗读 2026-09-16 22:55:58 +08:00
CodeBuddy 78bfe557c3 feat: 客户端 Android 与 H5 双端,含精排渲染层
按产品设计 §5「原生外壳 + WebView 渲染」落地客户端,Android 与浏览器共用同一份
渲染层;同时把服务端的格式范围从 epub + pdf 扩展到 txt 与漫画。

## 为什么两端共用一份渲染层

Android 端不重写渲染。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
渲染复用 web/ 构建出的同一份产物。原因很直接:排版策略一旦有第二份实现就一定会分叉,
而「忠于精排」正是这个产品的差异点。

共用的抽象在 web/src/core/platform.ts(传输、键值存储、二进制存储、连通性)。
外壳只覆盖 WebView 做不到或做不好的三件事:

- 连通性:WebView 里的 navigator.onLine 只要有网卡就报「在线」,连上没有出口的
  Wi-Fi 时会骗人,客户端于是转圈而不是读缓存。改用平台的 NET_CAPABILITY_VALIDATED。
- 稳定的设备名:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- 原生提示:一行 Kotlin 的 Toast,不用在 Web 层造通知 UI。

跨语言的接缝(Kotlin 桥接 ↔ TypeScript 声明)没法互相类型检查,因此加了
android/scripts/check-shell.sh 做双向契约校验,并接进 CI。

## 精排 CSS 策略

这是整个项目最产品化的一块 CSS,所以规则是写明的而不是凑出来的。

三条原则:不设任何属性除非不设会坏;每个可调项都是默认 inherit 的可继承自定义属性
(「关」的意思是「不动它」,不是「用我们的默认值」);任何改变作者意图的行为都是
用户主动开启且持久化。

实际 override 只有七处,每处都在注释里写了为什么:图片/表格/pre 在窄屏会撑破布局、
ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。

被刻意删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。它们能让排版
「看起来一致」,而一致性恰恰是这里要避免的。

书的样式装进 shadow root。书里一条 p { color: red } 不能改到书架,App 的 .topbar
也不能被书撞上。用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置并在 Android
上多花几倍内存。关键是**不往 shadow root 里注入任何 reset**。

## 格式层

服务端只负责流式给出原始文件,拆包、编码、章节与分页都是渲染层的事——
这些恰好是产品要自己拿主意的部分。

- EPUB:OPF/spine/nav 解析,作者样式全部保留,相对引用改写成内部 reader-res: 协议,
  绝对 URL 原样保留交给 CSP 挡掉。加载器按文档逐个容错,一个坏章节不会毁掉整本书。
- TXT:先严格校验 UTF-8(自己实现,不用宽松模式——宽松模式永远「成功」,于是永远
  发现不了解错了),失败再依次试 GB18030 与 Big5,并把选择告诉用户、允许覆盖。
  章节只能从标题推断,策略是宁可少认不要错认:800 字的段落以「第三章」开头不算标题,
  正文里提到「他在第二章读过」也不算。两条都有测试固定。
- CBZ / 图片目录:自然序排页(1,2,10 而不是 1,10,2),一页一个 fixed-layout section。
  排错不是观感问题,是故事被静默打乱。
- 检测以魔数优先:本地库里 .txt 其实是 EPUB、.cbz 里其实是 PDF 的情况很多,
  按扩展名开错加载器只会得到一个看不懂的报错。

服务端扫描器相应扩展:txt/cbz 直接索引;图片目录按文件夹识别为一本书,
文件夹路径作为主键锚点,所以**往漫画文件夹加一页不会丢进度**——这条有测试。

## 客户端三态与离线队列

离线队列记的是**脏集合**(哪些记录还没送到服务端),而不是「当前全部状态」。
两者只在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了。脏集合下这次更新仍是脏的,要等它自己的确认才清除;「全发」模式下它会被
那个更早的响应顺手清掉,然后永远送不到服务端——一个丢失的阅读位置,是这个产品
最不能有的失败。

阅读位置格式 r1:<偏移>:<章节 id>。三点都是踩过之后定的:版本前缀(服务端把 locator
当不透明字符串长期保存,客户端将来改格式必须能认出「这不是我认识的格式」)、
章节 id 放最后(它是文件路径,会含冒号,只从前两个冒号切才不会丢数据)、
偏移是章节内的比例而非像素或列号(列号会随字号变化,于是读者一调字号位置就跳)。

TXT、漫画、PDF 的支持也一并接上,PDF 用浏览器自带阅读器保底可读。

## 服务端

- GET /books/:id/file?path=… :按页取图。path 必须是这本书已登记的 rel_path,
  服务端按表精确匹配,不用 query 拼路径,所以穿越只会匹配到空。
- CORS 白名单(CORS_ORIGINS,默认回显任意来源)+ H5 静态托管(WEB_DIR)。
  托管在同一容器里,这才保住「一条命令跑起来」。
- Android 用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供
  打包好的客户端,而不是 file://。后者发出的 fetch 是不透明的跨源请求,所有访问
  服务端的调用都会被拦,而通常的绕法是把 web security 关掉——那等于把 WebView
  变成一扇敞开的门。用资产加载器拿到的是正常安全源,请求按普通 CORS 走,
  isSecureContext 为真,IndexedDB 等行为与浏览器一致。这一个选择是「一份 bundle
  两个宿主」能成立的前提。

## 过程中发现并修掉的问题

- **ETag 里塞了中文路径**:HTTP 头值必须是 ISO-8859-1,漫画页码路径是中文时会抛
  ERR_INVALID_CHAR,把一个正常的取图请求变成 500。改成对路径取散列。
- **漫画文件夹的主键用了页集合散列**:注释写的是「加一页不产生新书」,实现却是散列
  页集合,加一页就换 id、进度全丢。改成用文件夹路径作为锚点,和书用 dc:identifier
  作锚点是同一个道理。测试固定住了。
- **locator 解析与写出不对称**:写出是两段、解析却按三段切,导致位置根本存不回来。
  章节 id 含冒号时更糟。
- **离线队列的清理条件**:原本按「清空全部」处理,会把请求在途时产生的新位置一并
  抹掉。改成只清那一批、且只清时间戳吻合的条目。
- **测试自身的错误**:并发刷新去重的用例里,我一开始用 resolveRefresh?.() 调一个
  类型已收窄成 never 的变量,断言等于什么都没测;「outbox 为空时不推」的用例里把
  登录的 POST 也数了进去,断言恒不成立。两处都是测试写错了。
- **凭据用错了**:一个权限用例里我用 createAsAdmin 建账号却拿另一组凭据登录,
  401 被当成「越权被拒」而通过。这也是测试写错了。

## 验证方式

```
cd server && npm run typecheck && npm test && npm run build   # 37/37 通过
cd web    && npm run typecheck && npm test && npm run build   # 147/147 通过
sh android/scripts/check-shell.sh                             # 桥接契约双向校验通过
```

另用真实 HTTP 请求端到端验证过:注册 → 扫描 → 书架(四种格式全部可见)→ 漫画按页
取图(含中文路径 ETag)→ 列表页 → 进度同步 → 继续阅读;H5 静态托管、SPA 回落、
CORS 回显、路径穿越拒绝均按预期。

Android 壳本轮**没有真机编译验证**:环境里没有 JDK 和 Android SDK。Kotlin 侧做了
静态校验(包名与目录一致、项目内 import 可解析、桥接方法与 web 侧声明双向对齐),
并接进了 CI。第一次真机构建可能需要微调 Gradle 版本或 AGP 版本。

## 说明

- docker-compose.yml 与 Dockerfile 已改为从仓库根构建并打包 H5 产物;
  build 上下文变了,如果之前用了 `build: ./server` 需要改成根目录。
- iOS 未做设计(按设计文档 §7,等有赞助再说)。
- 移动端批注输入仍未做(先保证读,后补写)。

Ref: #1
2026-09-16 14:49:11 +08:00