Commit Graph
9 Commits
Author SHA1 Message Date
hectorqinandClaude Opus 5.5 2f784d1d4e fix(web): mobile PWA style review fixes
- Reader (scroll paging): mask the line the page's bottom edge cuts through
  (clip-path, --reader-page-mask) so no page shows a half-sliced last line;
  re-measured after page-turn animations.
- Landscape sidebar layout: the sidebar owns the left safe-area inset, the page
  column the right one; fix .shelf-head's swapped left/right padding; reader
  top/bottom bars respect left/right insets.
- Guard all :hover rules with (hover: hover) and (pointer: fine), enforced by
  test/design-tokens.test.ts.
- PWA update banner sits above the phone tab bar and reader toolbar.
- Top safe-area on sources header and phone media pages; 16px sidebar search
  on touch (iOS focus zoom); larger hit areas for chips and cover controls.
- Manifest: unified launch colour #FDF9F6, maskable icon, drop
  window-controls-overlay; regenerate branding assets.
- Docs: ui.md safe-area/touch/hover/reader rules, pwa.zh-CN.md icons and
  window notes, CLAUDE.md stale bundle notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:35:51 +00:00
Kiro Agent 858a7479bf feat(theme): V3.1 semantic tokens, four themes and a reading theme
Implements docs/prototypes/Reader V3 主题语义令牌.md (v3.1):

- 暖陶 (default), 雾蓝, 青绿 app themes plus the reading-only 黑白灰, each
  light and dark. Components read --color-* tokens from
  styles/theme-tokens.css, selected by data-theme + data-mode; the
  TypeScript mirror lives in shared/theme/theme.ts.
- Brand is split per the spec: brand/ch-* only fill (with on-brand/on-ch on
  top), brand-text/ch-*-text for text. --reader-accent/--media-accent are now
  brand text; --reader-fill/--media-fill are brand fills. Selected segmented
  controls use brand-soft + brand-text.
- One preference per server+account (reader.theme.v2): app theme, mode
  (system/light/dark), the follow-system light/dark pair, and the reading
  theme. V3 choices migrate per spec §8 (rose falls back to 暖陶 light).
- 外观与主题 rebuilt from prototype 23: follow-system switch with the
  light/dark pair, app-theme cards with light/dark previews, and the
  reading theme (跟随应用 / 黑白灰).
- Reader: 黑白灰 is set on the reader container only; the appearance panel
  offers 跟随应用/黑白灰 and 明暗; day/night sets the mode; the e-ink preset
  uses 黑白灰 light.
- Video and music players wear the current theme's dark values (§4.7).
- Removed the legacy light/sepia/green/dark palettes and the
  <html data-theme> writes from reader settings, which would now collide
  with data-theme="green".
- Tests: theme-tokens.test.ts keeps CSS and TS values identical and checks
  the §5 contrast pairs for all 8 palettes; theme.test.tsx covers migration,
  resolution, the controller and the settings page.
2026-10-20 04:05:35 +00:00
hectorqin 2aaa4280c7 refactor(web): migrate client to React architecture 2026-10-03 12:12:30 +08:00
hectorqin fc40e42d9b fix(media): disable document referrer for media requests 2026-10-01 13:57:10 +08:00
hectorqin 352dccaabe feat(pwa): align app branding and add startup screen 2026-10-01 08:26:08 +08:00
hectorqin aa7aee051d feat(sources): organize source login and plugin management 2026-09-24 10:32:47 +08:00
hectorqin e3cabf4b1f feat(web): add PWA installation and offline app shell 2026-09-24 06:34:21 +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
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