diff --git a/docs/superpowers/plans/2026-09-16-reader-revamp.md b/docs/superpowers/plans/2026-09-16-reader-revamp.md new file mode 100644 index 0000000..060e16a --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-reader-revamp.md @@ -0,0 +1,1295 @@ +# 阅读器改版(②)实现计划 / Reader revamp implementation plan + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 按 spec `docs/superpowers/specs/2026-09-16-reader-revamp-design.md` 落地六批次:B0 横切基建(prefs v2/主题联动/设置抽屉/统计层/tsc 修复)→ B1 CBZ(迁移+页模式+RTL)→ B2 Text(迁移+搜索+排版)→ B3 EPUB(迁移+排版)→ B4 PDF+统计卡 → B5 清理收严(删 ui.ts/icons.tsx/rd-*、eslint/axe 收严)。 + +**架构:** 全部改动限于 `frontend/` + `docs/`。纯逻辑抽 `lib/`(单测),chrome 换 shadcn token + radix 原语(新增 `ui/sheet|tabs|slider.tsx`),行为逻辑(章锁/连读/预读/书签/进度上报)原样保留。无后端改动、无 localStorage 兼容负担。 + +**技术栈:** React 19、radix-ui(已装)、shadcn 模式组件、vitest(unit=node / components=jsdom 双 project)、Playwright + axe(④ 基建)、epubjs themes、lucide-react。 + +**实施分支:** `feat/reader-revamp`(从 `docs/reader-revamp-spec` 建,spec 随实施分支合入)。**master 上不 commit。** + +## 全局约束 / Global constraints + +- npm 依赖只在 **web 容器内**装:`docker compose -f deploy/docker-compose.dev.yml exec web npm install ...`(宿主 docker 实为 podman,告警无害);lockfile 随批提交。`frontend/.npmrc` 已有 `legacy-peer-deps=true`,裸 `npm install` 即可。 +- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(dev 栈起、凭据 `set -a; source deploy/.env; set +a`,e2e 在容器内跑,命令模板见任务 10)。 +- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(`## [Unreleased]`,英中相邻两行、条目间空行、新条目在小节顶部),每批随批记。 +- **零新运行时依赖**:radix-ui/cva/lucide/clsx/tailwind-merge 已装;`ui/sheet|tabs|slider` 手写(模式对齐现有 `ui/dialog.tsx`:`import { X as XPrimitive } from "radix-ui"`、`data-slot` 属性、`cn()`)。dev 依赖仅可能新增 `@types/node`(任务 1)。 +- **行为保留红线**:翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报、编码回退、分章/分卷逻辑不改语义;④ 的渲染期重置修复不回退。`strip` 模式的 CbzReader 滚动/量高/重锚代码(现 L115-299)**一行不动**地保留。 +- 旧 localStorage 键 `cbz-continuous`/`cbz-prefetch` 废弃(不读不写不迁移);`reader-prefs` 换新结构宽容解析。 +- e2e 选择器锚点若与 DOM 实况有出入,以实况修选择器、用例语义不变(④ 惯例)。 +- prettier/eslint 对所有新文件生效;行级 disable 必须带中文理由注释。 + +## 文件结构 / File structure + +**B0 创建/修改:** +- 修改 `frontend/tsconfig.json`(include 扩 e2e/*.config.ts)、`frontend/vitest.config.ts`(components include 收紧为 `src/**/*.test.{ts,tsx}`) +- 重写 `frontend/src/lib/readerPrefs.ts`(v2 结构)+ 新建 `frontend/test/readerPrefs.test.ts` +- 修改 `frontend/src/components/theme.tsx`(导出 `useGlobalDark`) +- 修改 `frontend/src/index.css`(--rd-* token 组扩展) +- 新建 `frontend/src/components/ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx` +- 新建 `frontend/src/components/reader-settings.tsx` + `frontend/src/components/reader-settings.test.tsx` +- 新建 `frontend/src/lib/readingStats.ts` + `frontend/test/readingStats.test.ts` +- 新建 `frontend/src/lib/useReadingSession.ts` + +**B1:** 新建 `frontend/src/lib/cbzPages.ts` + `frontend/test/cbzPages.test.ts`;修改 `pages/Reader.tsx`、`components/reader-nav.tsx`、`components/Bookmarks.tsx`、`readers/CbzReader.tsx`(PageView 新渲染路径 + reader-settings 接线);删 `components/rd-slider.tsx`;e2e 扩展。 + +**B2:** 新建 `frontend/src/lib/search.ts` + `frontend/test/search.test.ts`、`e2e/fixtures/e2e-sample.txt`;修改 `readers/TextReader.tsx`、`components/reader-nav.tsx`(搜索 tab);e2e 搜索冒烟。 + +**B3:** 修改 `readers/EpubReader.tsx`。 + +**B4:** 修改 `readers/PdfReader.tsx`、`pages/Shelf.tsx`(统计卡)、`pages/Reader.tsx`(useReadingSession 接线);新建 `components/reading-stats-card.tsx` + 测试。 + +**B5:** 删 `components/ui.ts`、`components/icons.tsx`;新建 `components/format-badge.tsx`;修改 `index.css`(rd-* 组件类删除)、`eslint.config.js`(reader override 删除)、`e2e/helpers/axe.ts`(WAIVERS 清空)、Shelf/Reader 等使用点。 + +--- + +## B0 批次 0:横切基建 + +### 任务 1:tsc 盲区修复 + vitest include 收紧 + +**文件:** +- 修改:`frontend/tsconfig.json`、`frontend/vitest.config.ts`、`frontend/package.json`(devDep) + +- [ ] **步骤 1.1:容器内装 `@types/node`** + +```bash +docker compose -f deploy/docker-compose.dev.yml exec web npm install -D @types/node +``` + +- [ ] **步骤 1.2:`frontend/tsconfig.json` 两处修改** + +`"include": ["src", "test"]` 改为 `"include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]`;`"types": ["vite/client", "vite-plugin-pwa/client"]` 改为 `"types": ["vite/client", "vite-plugin-pwa/client", "node"]`。 + +- [ ] **步骤 1.3:`frontend/vitest.config.ts` components project 的 include 收紧** + +`include: ["src/**/*.test.tsx"]` 改为 `include: ["src/**/*.test.{ts,tsx}"]`(消除 `src/**/*.test.ts` 静默缝隙——④ 账本 backlog 项),并同步更新文件顶部注释(「放 test/ 下的 .test.tsx 不会被捕获」的说明仍成立)。 + +- [ ] **步骤 1.4:验证** + +```bash +docker compose -f deploy/docker-compose.dev.yml exec web npx tsc --noEmit +``` + +预期:0 error(e2e/config 首次纳入类型检查;若报错,修类型问题——`process.env` 等由 @types/node 解决;**不许**用 exclude 回避)。随后 `npm run check` 全绿。 + +- [ ] **步骤 1.5:Commit** + +```bash +git add frontend/tsconfig.json frontend/vitest.config.ts frontend/package.json frontend/package-lock.json +git commit -m "chore(frontend): extend tsc coverage to e2e/configs, tighten vitest include (B0)" +``` + +### 任务 2:readerPrefs v2(TDD) + +**文件:** +- 重写:`frontend/src/lib/readerPrefs.ts` +- 测试:`frontend/test/readerPrefs.test.ts` + +- [ ] **步骤 2.1:先写失败的单测 `frontend/test/readerPrefs.test.ts`** + +```ts +import { describe, expect, it } from "vitest"; +import { DEFAULT_PREFS, parsePrefs, resolveRdTheme } from "../src/lib/readerPrefs"; + +describe("parsePrefs", () => { + it("returns defaults for null/garbage", () => { + expect(parsePrefs(null)).toEqual(DEFAULT_PREFS); + expect(parsePrefs("{oops")).toEqual(DEFAULT_PREFS); + }); + it("merges partial legacy-free structures with defaults", () => { + const p = parsePrefs(JSON.stringify({ themeMode: "night", cbz: { mode: "page" } })); + expect(p.themeMode).toBe("night"); + expect(p.cbz).toEqual({ ...DEFAULT_PREFS.cbz, mode: "page" }); + expect(p.text).toEqual(DEFAULT_PREFS.text); + }); + it("rejects out-of-range values back to defaults", () => { + const p = parsePrefs( + JSON.stringify({ text: { sizeIdx: 99, lineIdx: -1, margin: "huge" }, cbz: { prefetch: 7 }, epub: { sizeIdx: 2, lineIdx: 0, marginIdx: 9 } }), + ); + expect(p.text).toEqual(DEFAULT_PREFS.text); + expect(p.cbz.prefetch).toBe(DEFAULT_PREFS.cbz.prefetch); + expect(p.epub.marginIdx).toBe(DEFAULT_PREFS.epub.marginIdx); + }); +}); + +describe("resolveRdTheme", () => { + it("auto follows global dark/light", () => { + expect(resolveRdTheme("auto", true)).toBe("night"); + expect(resolveRdTheme("auto", false)).toBe("paper"); + }); + it("manual overrides global", () => { + expect(resolveRdTheme("sepia", true)).toBe("sepia"); + expect(resolveRdTheme("night", false)).toBe("night"); + }); +}); +``` + +- [ ] **步骤 2.2:跑测试确认失败** + +`docker compose -f deploy/docker-compose.dev.yml exec web npx vitest run readerPrefs` — 预期 FAIL(parsePrefs/resolveRdTheme/DEFAULT_PREFS 不存在)。 + +- [ ] **步骤 2.3:重写 `frontend/src/lib/readerPrefs.ts`** + +```ts +import { useCallback, useEffect, useState } from "react"; + +export const RD_THEMES = ["paper", "sepia", "night"] as const; +export type RdTheme = (typeof RD_THEMES)[number]; +export const RD_THEME_LABEL: Record = { paper: "纸", sepia: "米", night: "夜" }; +export const FONT_SIZES = [16, 18, 20, 22, 25, 28]; +export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0]; +export const MARGINS = ["narrow", "medium", "wide"] as const; +export type Margin = (typeof MARGINS)[number]; +export const MARGIN_LABEL: Record = { narrow: "窄", medium: "中", wide: "宽" }; +/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */ +export const MARGIN_MAXW: Record = { narrow: "42em", medium: "34em", wide: "26em" }; +export const CBZ_MODES = ["strip", "page", "spread"] as const; +export type CbzMode = (typeof CBZ_MODES)[number]; +export const CBZ_MODE_LABEL: Record = { strip: "长卷", page: "单页", spread: "双页" }; + +const KEY = "reader-prefs"; + +export interface ReaderPrefs { + themeMode: "auto" | RdTheme; + text: { sizeIdx: number; lineIdx: number; margin: Margin }; + cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number }; + epub: { sizeIdx: number; lineIdx: number; marginIdx: number }; +} + +export const DEFAULT_PREFS: ReaderPrefs = { + themeMode: "auto", + text: { sizeIdx: 2, lineIdx: 2, margin: "medium" }, + cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 }, + epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 }, +}; + +function idx(v: unknown, len: number, def: number): number { + return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def; +} + +/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */ +export function parsePrefs(raw: string | null): ReaderPrefs { + let o: Record = {}; + try { + const parsed: unknown = JSON.parse(raw ?? ""); + if (parsed && typeof parsed === "object") o = parsed as Record; + } catch { + /* 首次使用或坏值 */ + } + const t = (o.text ?? {}) as Record; + const c = (o.cbz ?? {}) as Record; + const e = (o.epub ?? {}) as Record; + const mode = o.themeMode; + return { + themeMode: mode === "auto" || RD_THEMES.includes(mode as RdTheme) ? (mode as ReaderPrefs["themeMode"]) : DEFAULT_PREFS.themeMode, + text: { + sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx), + lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx), + margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin, + }, + cbz: { + mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode, + rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl, + continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous, + prefetch: Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3 ? (c.prefetch as number) : DEFAULT_PREFS.cbz.prefetch, + }, + epub: { + sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx), + lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx), + marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx), + }, + }; +} + +/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */ +export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme { + if (mode !== "auto") return mode; + return globalDark ? "night" : "paper"; +} + +export function useReaderPrefs() { + const [prefs, setPrefs] = useState(() => parsePrefs(localStorage.getItem(KEY))); + useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]); + const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []); + return { + prefs, + update, + fontSize: FONT_SIZES[prefs.text.sizeIdx], + lineHeight: LINE_HEIGHTS[prefs.text.lineIdx], + marginMaxW: MARGIN_MAXW[prefs.text.margin], + epubFontSize: FONT_SIZES[prefs.epub.sizeIdx], + setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })), + bumpText: (d: number) => + update((p) => ({ ...p, text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) } })), + bumpEpub: (d: number) => + update((p) => ({ ...p, epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) } })), + }; +} + +export type ReaderPrefsApi = ReturnType; +``` + +**注意**:现有消费点(TextReader)在本任务后会暂时类型不匹配(`pr.prefs.theme`/`pr.setTheme`/`pr.bump` 不存在)——**预期红**,B2 任务 12 迁移 TextReader 时修复。为保持每任务 check 绿,本任务同步做**最小适配**:TextReader 内 `pr.prefs.theme` → `resolveRdTheme(pr.prefs.themeMode, false)`(临时,B2 换成 useGlobalDark 版)、`pr.setTheme(t)` → `pr.setThemeMode(t)`、`pr.bump(d)` → `pr.bumpText(d)`;仅改这三个调用点,不做其他迁移。 + +- [ ] **步骤 2.4:跑测试确认通过 + check 全绿** + +`npx vitest run readerPrefs` → PASS;`npm run check` → exit 0。 + +- [ ] **步骤 2.5:Commit** + +```bash +git add frontend/src/lib/readerPrefs.ts frontend/test/readerPrefs.test.ts frontend/src/readers/TextReader.tsx +git commit -m "feat(frontend): readerPrefs v2 unified structure (TDD)" +``` + +### 任务 3:主题 token 扩展 + useGlobalDark + +**文件:** +- 修改:`frontend/src/index.css`、`frontend/src/components/theme.tsx` + +- [ ] **步骤 3.1:`index.css` 三个 `[data-rd=...]` 块各补三个 token** + +在现有 `--rd-bg/--rd-fg/--rd-link` 之后(值与既有色板协调,chroma 略降): + +```css +[data-rd="paper"] { + /* 既有 3 行不动 */ + --rd-muted: #8a8378; + --rd-accent: #b45309; + --rd-border: #e4ded2; +} +[data-rd="sepia"] { + --rd-muted: #857a63; + --rd-accent: #92400e; + --rd-border: #ddd0b4; +} +[data-rd="night"] { + --rd-muted: #8b8579; + --rd-accent: #e0a458; + --rd-border: #322e27; +} +``` + +- [ ] **步骤 3.2:`theme.tsx` 追加导出 `useGlobalDark`(文件末尾)** + +```tsx +const MQ = "(prefers-color-scheme: dark)"; + +/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */ +export function useGlobalDark(): boolean { + const { theme } = useTheme(); + const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches); + useEffect(() => { + if (theme !== "system") return; + const mq = matchMedia(MQ); + const on = () => setSysDark(mq.matches); + mq.addEventListener("change", on); + return () => mq.removeEventListener("change", on); + }, [theme]); + return theme === "dark" || (theme === "system" && sysDark); +} +``` + +(`useState/useEffect` 已在该文件 import。) + +- [ ] **步骤 3.3:验证 + Commit** + +`npm run check` 绿(本任务无新消费点,编译即验证)。 + +```bash +git add frontend/src/index.css frontend/src/components/theme.tsx +git commit -m "feat(frontend): reading theme token group + useGlobalDark for auto linkage (B0)" +``` + +### 任务 4:ui/sheet + ui/tabs + ui/slider + +**文件:** +- 创建:`frontend/src/components/ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx` + +- [ ] **步骤 4.1:手写三个 shadcn 原语(模式对齐 `ui/dialog.tsx`:`radix-ui` 单包导入、`data-slot`、`cn()`)** + +`sheet.tsx`——Sheet = Dialog 原语 + side 变体(cva),导出 `Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription`;`SheetContent` 带 `side?: "top"|"right"|"bottom"|"left"`(默认 right),各 side 的 fixed 定位/滑入动画 class 用 shadcn 标准写法(`data-[state=open]:slide-in-from-left` 等,tailwind v4 + tw-animate-css 已装);含 Overlay(`bg-black/50`)与右上角关闭钮(side=right/top 时)。 + +`tabs.tsx`——Tabs 原语封装,导出 `Tabs, TabsList, TabsTrigger, TabsContent`;TabsList `bg-muted rounded-lg p-1`,TabsTrigger `data-[state=active]:bg-background data-[state=active]:text-foreground` 标准样式。 + +`slider.tsx`——Slider 原语封装,导出 `Slider`(props 透传 `React.ComponentProps`);标准结构:Track(`bg-muted h-1.5 rounded-full`)+ Range(`bg-primary`)+ Thumb(`border-primary/50 bg-background size-4 rounded-full shadow`,`block` 类补 focus-visible 环)。多 thumb 支持按 `value` 数组长度渲染 Thumb(shadcn 标准 `Array.from({length: ...})` 写法)。 + +三个文件全部带 `data-slot` 属性、无 `any`、prettier 干净。**验收标准**(审查用):`SheetContent side="left"` 能作为导航抽屉容器;`Slider` 受控 `value={[n]}` + `onValueChange`;`Tabs` 受控 `value` + `onValueChange`。 + +- [ ] **步骤 4.2:验证 + Commit** + +`npm run check` 绿。 + +```bash +git add frontend/src/components/ui/sheet.tsx frontend/src/components/ui/tabs.tsx frontend/src/components/ui/slider.tsx +git commit -m "feat(frontend): shadcn sheet/tabs/slider primitives (B0)" +``` + +### 任务 5:reader-settings 统一设置抽屉(TDD 组件测试) + +**文件:** +- 创建:`frontend/src/components/reader-settings.tsx`、`frontend/src/components/reader-settings.test.tsx` + +- [ ] **步骤 5.1:组件 API 设计(先写进文件头注释再实现)** + +```tsx +export interface ReaderSettingsProps { + /** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数 */ + slider: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string }; + /** 位置文本,如 "3/12 · 25%" */ + position?: string; + /** 左半区按钮(导航/上一章/下一章等),调用方给 */ + left?: ReactNode; + /** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */ + showFont?: boolean; + /** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */ + extra?: ReactNode; +} +``` + +内部:`useReaderPrefs()` + `useGlobalDark()` + `resolveRdTheme` 得当前有效主题;常驻行 = Slider + position + left 区 + 主题卡(三枚色卡按钮,aria-label `${RD_THEME_LABEL[t]}色主题`,`aria-pressed` = 当前手动值;auto 态时额外一枚「跟随全局」按钮 aria-pressed=true,点色卡即转手动,点「跟随」回 auto)+ showFont 时 A−/A+(aria-label 减小字号/加大字号,复用 ④ 前的文案)+ extra 存在时「更多设置」展开钮(aria-label="更多阅读设置",aria-expanded,ChevronUp/Down 图标)。展开区渲染 extra。容器样式:shadcn token(`border-t bg-background/95 text-foreground backdrop-blur`)+ safe-area padding(沿用 `pb-[max(0.5rem,env(safe-area-inset-bottom))]`)。 + +**注意**:本组件是普通底栏(Text 的 grid 行)与覆盖层(CBZ 的 absolute)两用——定位类由调用方包一层 div 决定,组件本身不含 absolute/fixed。 + +- [ ] **步骤 5.2:组件测试 `reader-settings.test.tsx`(测试需在 ThemeProvider 内渲染)** + +覆盖:① 常驻行渲染(滑条 aria-label、位置文本);② 主题卡点击→localStorage `reader-prefs` 的 themeMode 变手动值;③「跟随全局」按钮存在且点击回 auto;④ showFont=false 时无 A−/A+;⑤「更多设置」展开/收起(aria-expanded 翻转、extra 内容可见性);⑥ A+ 点击→text.sizeIdx 增加。jsdom 无 matchMedia 时用 ④ 建好的 stub(setup.ts 已配)。 + +- [ ] **步骤 5.3:验证 + Commit** + +`npx vitest run reader-settings` 绿 → `npm run check` 绿 → + +```bash +git add frontend/src/components/reader-settings.tsx frontend/src/components/reader-settings.test.tsx +git commit -m "feat(frontend): unified reader settings bar with theme linkage (B0)" +``` + +### 任务 6:readingStats 统计层(TDD)+ useReadingSession + +**文件:** +- 创建:`frontend/src/lib/readingStats.ts`、`frontend/test/readingStats.test.ts`、`frontend/src/lib/useReadingSession.ts` + +- [ ] **步骤 6.1:先写失败单测 `frontend/test/readingStats.test.ts`** + +覆盖纯函数:`dayKey`(本地时区 YYYY-MM-DD)、`weeklySum`(近 7 天含今天的按日数组与合计、缺日补 0)、`streakOf`(今天有记录→连续天数;今天无但昨天有→连续天数;断档即止;空→0)、`recordInto`(不可变累加)。 + +- [ ] **步骤 6.2:实现 `frontend/src/lib/readingStats.ts`** + +```ts +export interface DayBucket { day: string; seconds: number } +export interface StatsStore { + record(seconds: number): void; + weekly(): { perDay: DayBucket[]; total: number }; + streak(): number; +} + +const KEY = "reading-stats"; +export type Buckets = Record; + +export function dayKey(d: Date): string { + const p = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; +} +export function recordInto(b: Buckets, day: string, seconds: number): Buckets { + return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) }; +} +export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } { + const perDay: DayBucket[] = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const day = dayKey(d); + perDay.push({ day, seconds: b[day] ?? 0 }); + } + return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) }; +} +export function streakOf(b: Buckets, today: Date): number { + let n = 0; + const d = new Date(today); + if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断 + while (b[dayKey(d)] > 0) { + n++; + d.setDate(d.getDate() - 1); + } + return n; +} + +function load(): Buckets { + try { + const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}"); + return o && typeof o === "object" ? (o as Buckets) : {}; + } catch { + return {}; + } +} + +/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */ +export function createLocalStats(): StatsStore { + return { + record(seconds) { + if (seconds <= 0) return; + localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds))); + }, + weekly() { + return weeklySum(load(), new Date()); + }, + streak() { + return streakOf(load(), new Date()); + }, + }; +} +``` + +- [ ] **步骤 6.3:`frontend/src/lib/useReadingSession.ts`** + +```ts +import { useEffect, useRef } from "react"; +import { createLocalStats, type StatsStore } from "./readingStats"; + +const TICK = 30; // 秒:可见期间心跳粒度 + +/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */ +export function useReadingSession(store: StatsStore = createLocalStats()) { + const acc = useRef(0); + const last = useRef(Date.now()); + useEffect(() => { + const flush = () => { + const now = Date.now(); + if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000; + last.current = now; + if (acc.current >= 1) { + store.record(acc.current); + acc.current = 0; + } + }; + const t = setInterval(flush, TICK * 1000); + const onVis = () => flush(); + document.addEventListener("visibilitychange", onVis); + globalThis.addEventListener?.("pagehide", onVis); + return () => { + clearInterval(t); + document.removeEventListener("visibilitychange", onVis); + globalThis.removeEventListener?.("pagehide", onVis); + flush(); + }; + }, [store]); +} +``` + +(store 默认参数每次渲染新建会重跑 effect——调用方在模块级或 useMemo 传入单例;Reader.tsx 接线时 `const stats = useMemo(() => createLocalStats(), [])`。) + +- [ ] **步骤 6.4:验证 + Commit** + +`npx vitest run readingStats` 绿 → `npm run check` 绿 → + +```bash +git add frontend/src/lib/readingStats.ts frontend/test/readingStats.test.ts frontend/src/lib/useReadingSession.ts +git commit -m "feat(frontend): reading stats store (TDD) + session heartbeat hook (B0)" +``` + +**B0 changelog**(任务 6 commit 时一并写入 `docs/CHANGELOG_web.md` Added 顶部,双语): + +```markdown +- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card. +- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。 +``` + +--- + +## B1 批次 1:CBZ(迁移 + 页模式 + RTL) + +### 任务 7:cbzPages 纯函数(TDD) + +**文件:** +- 创建:`frontend/src/lib/cbzPages.ts`、`frontend/test/cbzPages.test.ts` + +- [ ] **步骤 7.1:先写失败单测**,覆盖: + +```ts +import { describe, expect, it } from "vitest"; +import { spreadPages, pageIndexToView, turnView, viewCount, viewToPageIndex } from "../src/lib/cbzPages"; + +describe("cbzPages", () => { + it("page mode: one page per view", () => { + expect(viewCount(5, "page")).toBe(5); + expect(viewToPageIndex(2, "page")).toBe(2); + expect(pageIndexToView(2, "page")).toBe(2); + }); + it("spread mode: pairs [0,1] [2,3] [4]", () => { + expect(viewCount(5, "spread")).toBe(3); + expect(pageIndexToView(3, "spread")).toBe(1); + expect(viewToPageIndex(1, "spread")).toBe(2); + expect(spreadPages(2, 5, false)).toEqual([2, 3]); + expect(spreadPages(4, 5, false)).toEqual([4]); // 落单页 + expect(spreadPages(2, 5, true)).toEqual([3, 2]); // RTL 右页为先 + }); + it("turnView clamps and RTL flips direction", () => { + expect(turnView(0, 1, false, 3)).toBe(1); + expect(turnView(2, 1, false, 3)).toBe(2); // 右边界钳制 + expect(turnView(0, -1, false, 3)).toBe(0); // 左边界钳制 + expect(turnView(1, 1, true, 3)).toBe(0); // RTL:d=+1 变后退 + }); +}); +``` + +- [ ] **步骤 7.2:实现 `frontend/src/lib/cbzPages.ts`** + +```ts +export type PageMode = "page" | "spread"; + +export function viewCount(count: number, mode: PageMode): number { + return mode === "page" ? count : Math.ceil(count / 2); +} +export function pageIndexToView(i: number, mode: PageMode): number { + return mode === "page" ? i : Math.floor(i / 2); +} +export function viewToPageIndex(v: number, mode: PageMode): number { + return mode === "page" ? v : v * 2; +} +/** 一个 spread 视图要渲染的页,按显示顺序(LTR=[左,右];RTL=[右,左] 即先读页在前)。 */ +export function spreadPages(first: number, count: number, rtl: boolean): number[] { + const pages = [first, ...(first + 1 < count ? [first + 1] : [])]; + return rtl ? [...pages].reverse() : pages; +} +/** 翻一屏/页;rtl 时方向翻转;越界钳制。 */ +export function turnView(v: number, d: number, rtl: boolean, views: number): number { + const step = rtl ? -d : d; + return Math.max(0, Math.min(views - 1, v + step)); +} +``` + +- [ ] **步骤 7.3:绿 → Commit** + +```bash +git add frontend/src/lib/cbzPages.ts frontend/test/cbzPages.test.ts +git commit -m "feat(frontend): cbz page/spread view math (TDD)" +``` + +### 任务 8:共享 chrome 迁移(Reader 头栏 / reader-nav / Bookmarks) + +**文件:** +- 修改:`frontend/src/pages/Reader.tsx`、`frontend/src/components/reader-nav.tsx`、`frontend/src/components/Bookmarks.tsx` +- 删除:`frontend/src/components/rd-slider.tsx`(唯一使用点 CbzReader 的预读滑条将在任务 9 移入 reader-settings;本任务先删文件会破坏编译——**顺序**:本任务先把 CbzReader 里 `RdSlider` 的 import 与使用处**临时替换**为等价的原生 range(保留 `rd-range` class 与 aria-label「预读章数」,任务 9 会整体换掉),再删 rd-slider.tsx) + +- [ ] **步骤 8.1:`pages/Reader.tsx` 头栏迁移(行为不变,只换壳)** + +映射表(现 L52-70): +- `
` 类:`border-stone-800/80 bg-stone-950/80` → `border-border bg-background/90 text-foreground`;高度/过渡/收放逻辑(chromeOn 三元)不变; +- 返回 Link:`btnGhost` → ` + {chapters && ( + <> + {ci + 1}/{chapters.length} + + + + )} + + } + extra={ + <> + + {CBZ_MODES.map((m) => ( + + ))} + + {prefs.cbz.mode !== "strip" && ( + + + + )} + {chapters && chapters.length >= 2 && ( + + + + )} + {locked && ( + + patchCbz({ prefetch: v })} aria-label="预读章数" /> + + )} + + } + /> + +)} +``` + +`SettingRow`(本文件内小组件):`
{label}{children}
`。`setMode(m)`:切到 page/spread 时把当前 `cur` 页换算为新视图起点(任务 9.2 的 pv state 同步),切回 strip 时 `goPage(viewToPageIndex(pv, mode))` 落位。旧「更多」DropdownMenu(L417-456)与旧滑条/HUD 块整体删除;chrome.off 时的右下角 HUD(L459-466)保留但换 token:`bg-stone-900/85 ring-stone-700/60 text-stone-200` → `bg-background/85 ring-border text-foreground`。 + +- [ ] **步骤 9.2:page/spread 渲染路径(新组件,同文件内)** + +在 CbzReader 组件内按 `prefs.cbz.mode` 分支:`strip` 走现有 JSX(L301-363 的滚动容器 + 量高 + 章末卡片 + 预读层,**一行不动**);`page`/`spread` 渲染 ``: + +```tsx +function PageView({ + count, chapters, locked, ci, setCi, continuous, fmt, rtl, mode, saver, chrome, initialPage, +}: { + count: number; + chapters: { title: string; start: number }[] | null; + locked: boolean; + ci: number; + setCi: (i: number) => void; + continuous: boolean; + fmt: string; + rtl: boolean; + mode: PageMode; + saver: ReturnType; + chrome: Chrome; + initialPage: number; +}) { + // 章窗口:locked 时视图范围 = 本章页;continuous 时全书 + const win = locked && chapters ? { start: chapters[ci].start, end: ci + 1 < chapters.length ? chapters[ci + 1].start : count } : { start: 0, end: count }; + const winViews = viewCount(win.end - win.start, mode); + const [v, setV] = useState(() => Math.min(winViews - 1, pageIndexToView(Math.max(win.start, Math.min(win.end - 1, initialPage)) - win.start, mode))); + const atEnd = v >= winViews - 1; // 章末卡片视图 = winViews(虚拟追加一格) + const [showEndCard, setShowEndCard] = useState(false); + + const page0 = win.start + viewToPageIndex(v, mode); + useEffect(() => { + if (showEndCard) return; + saver.report({ page: page0 }, (page0 + 1) / count); + }, [page0, showEndCard, count, saver]); + + function turn(d: number) { + chrome.show(); + if (showEndCard) { + if (d > 0) advanceChapter(); + else setShowEndCard(false); + return; + } + const nv = turnView(v, d, rtl, winViews); + if (nv === v && atEnd && (rtl ? d < 0 : d > 0)) { + // 章末:locked 且非 continuous → 卡片;否则进下一章 + if (locked && !continuous) setShowEndCard(true); + else advanceChapter(); + return; + } + setV(nv); + } + function advanceChapter() { + if (!chapters || ci + 1 >= chapters.length) return; // 全书完:卡片显示「— 全书完 —」 + setCi(ci + 1); + setShowEndCard(false); + setV(0); + } + // ci 变化(外部 goPage/目录/书签)时重定位视图 + ... +} +``` + +**实现者注意**(计划给结构与语义,细节以现有 strip 路径的语义为准对齐): +- 外部 `goPage(i)`(书签/目录/滑条/恢复进度)在页模式下 = `setCi(chapterIndexAt(chapters, i))`(locked 时)+ 换算 `v`;用 `pending` ref 模式与现有代码一致(ci 生效后落位)。 +- 渲染:`spreadPages(page0, win.end, rtl)` 得到的页数组,每页 ``;单页宽 = `min(容器宽, MAX_W)`,双页各占一半(flex 行,`items-center justify-center`,容器 `h-full overflow-hidden` 点区沿用 `onZoneClick` 语义:两侧翻页(RTL 由 turn 内部翻转)、中央 toggle chrome)。 +- 相邻预载:`useEffect` 里对 `page0±2` 调 `fetchObjectUrl(formatPageUrl(fmt, j)).catch(() => {})`。 +- 章末卡片:`showEndCard` 时全屏卡片(`win.end >= count ? "— 全书完 —" : `),样式对齐现有 strip 章末卡片(shadcn token)。 +- 键盘:外层容器 onKeyDown ←/→/PageUp/PageDown → `turn(±1)`(RTL 翻转在 turn 内);容器 tabIndex={0}(沿用 ④ 的 a11y 修复)。 +- 页码 HUD/滑条读数:`cur` 在页模式下 = `page0`(供 position 文本与 slider value)。 +- `initialPage`:挂载时来自恢复进度/当前 strip 位置。 + +- [ ] **步骤 9.3:验证** + +`npm run check` 绿;`npm run e2e` 绿(现有 auth-shelf 的 CBZ 流程是 strip 模式,应无回归);手工走查清单:strip 全部旧行为(章锁/连读/预读/滑条/书签/目录/进度恢复)+ page/spread 翻页、RTL 方向、双页配对、章末卡片、模式切换保持当前页、主题卡对 CBZ 背景生效(`--rd-bg` 用于页模式容器底色)。 + +- [ ] **步骤 9.4:Commit(含 B1 changelog,双语,Added 顶部)** + +changelog 文案: + +```markdown +- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support. +- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。 +``` + +```bash +git add frontend docs/CHANGELOG_web.md +git commit -m "feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1)" +``` + +### 任务 10:CBZ 测试与 e2e 扩展 + +**文件:** +- 创建:`frontend/src/readers/CbzReader.pagemode.test.tsx`(组件测试,可测部分) +- 修改:`frontend/e2e/auth-shelf.spec.ts`(页模式 + RTL 冒烟段) + +- [ ] **步骤 10.1:组件测试**:PageView 依赖网络图片与 react-query,全组件测试成本高——本任务测**可注入部分**:设置面板 extra 区的模式切换按钮渲染与点击回调(通过 ReaderSettings 单测已覆盖展开逻辑,此处补 CbzReader 特有:mock `api.pageCount` 返回 3 页扁平书,渲染 CbzReader,切「单页」→ 断言出现页视图容器(aria-label 含「漫画阅读器」仍成立)与 position 文本 `1/3`;切「双页」→ position 仍 `1/3`(首页不变)。fetch/ObjectURL 用 vi.stubGlobal mock。**若 mock 面过大导致测试脆弱,降级为只测设置区交互 + lib 纯函数(任务 7 已覆盖),并在报告说明**——不许写空洞断言凑数。 + +- [ ] **步骤 10.2:e2e 扩展**:auth-shelf.spec.ts 的阅读器段落之后追加:打开「更多阅读设置」→ 点「单页」→ 断言 position 含 `1/3` → 右缘点击 → `2/3` → 点「右开本(RTL)」→ 右缘点击 → 回 `1/3` → 切回「长卷」。选择器以 DOM 实况为准(aria-label「更多阅读设置」、按钮文本 单页/右开本(RTL)/长卷)。 + +- [ ] **步骤 10.3:验证 + Commit** + +`npm run check` 绿 + `npm run e2e` 全绿。 + +```bash +git add frontend +git commit -m "test(frontend): CBZ page-mode component test + e2e mode/RTL smoke (B1)" +``` + +--- + +## B2 批次 2:Text(迁移 + 搜索 + 排版) + +### 任务 11:search 纯函数(TDD) + +**文件:** +- 创建:`frontend/src/lib/search.ts`、`frontend/test/search.test.ts` + +- [ ] **步骤 11.1:先写失败单测**,覆盖: + +```ts +import { describe, expect, it } from "vitest"; +import { searchChapters, splitHighlight } from "../src/lib/search"; +import { splitChapters } from "../src/lib/chapters"; + +const TEXT = ["第一章 起风", "风来了又走。", "第二章 落雨", "雨点敲窗,风声相伴。", "风止"].join("\n"); + +describe("searchChapters", () => { + const chs = splitChapters(TEXT); + it("finds case-insensitive hits with chapter index and position", () => { + const hits = searchChapters(TEXT, chs, "风"); + expect(hits.length).toBeGreaterThanOrEqual(3); + expect(hits[0].ch).toBe(0); + expect(hits.every((h) => h.snippet.includes("风"))).toBe(true); + }); + it("returns empty for no match / empty query", () => { + expect(searchChapters(TEXT, chs, "不存在")).toEqual([]); + expect(searchChapters(TEXT, chs, " ")).toEqual([]); + }); + it("caps at limit", () => { + expect(searchChapters(TEXT, chs, "风", 2)).toHaveLength(2); + }); +}); + +describe("splitHighlight", () => { + it("splits snippet into before/match/after", () => { + expect(splitHighlight("abc风def", 3, 1)).toEqual({ before: "abc", match: "风", after: "def" }); + }); +}); +``` + +- [ ] **步骤 11.2:实现 `frontend/src/lib/search.ts`** + +```ts +import { chapterText, type TxtChapter } from "./chapters"; + +export interface SearchHit { + ch: number; + /** 命中在章内文本的起点 */ + pos: number; + snippet: string; + matchStart: number; // snippet 内 + matchLen: number; +} + +const CTX = 20; // snippet 前后文 + +/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */ +export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] { + const needle = q.trim().toLowerCase(); + if (!needle) return []; + const hits: SearchHit[] = []; + for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) { + const body = chapterText(text, chapters, ci).toLowerCase(); + let from = 0; + for (;;) { + const at = body.indexOf(needle, from); + if (at < 0 || hits.length >= limit) break; + const raw = chapterText(text, chapters, ci); + const s = Math.max(0, at - CTX); + const e = Math.min(raw.length, at + needle.length + CTX); + hits.push({ + ch: ci, + pos: at, + snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""), + matchStart: at - s + (s > 0 ? 1 : 0), + matchLen: needle.length, + }); + from = at + needle.length; + } + } + return hits; +} + +export function splitHighlight(snippet: string, matchStart: number, matchLen: number) { + return { + before: snippet.slice(0, matchStart), + match: snippet.slice(matchStart, matchStart + matchLen), + after: snippet.slice(matchStart + matchLen), + }; +} +``` + +(性能注意:`chapterText` 在循环里重复调用——实现时可先把各章文本缓存进数组再扫,以测试为准优化,接口不变。) + +- [ ] **步骤 11.3:绿 → Commit** + +```bash +git add frontend/src/lib/search.ts frontend/test/search.test.ts +git commit -m "feat(frontend): in-book text search core (TDD)" +``` + +### 任务 12:TextReader 迁移 + 搜索 tab + 排版设置 + +**文件:** +- 修改:`frontend/src/readers/TextReader.tsx`、`frontend/src/components/reader-nav.tsx`(search 内容传入已支持,无需再改) + +- [ ] **步骤 12.1:删除本地 `ReaderSheet`(L60-127)与 `SWATCH`,MdView/TxtView 换 ReaderSettings** + +- `pr = useReaderPrefs()` 保留;新增 `const globalDark = useGlobalDark(); const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);`,`data-rd={rd}`(替换 `pr.prefs.theme`,两处 L179/L302); +- 任务 2 的临时适配(`resolveRdTheme(pr.prefs.themeMode, false)`)换为上述真实联动; +- MdView 底部(chrome.on 时):` seek(v/1000), ariaLabel: "阅读进度" }} position={`${Math.round(frac*100)}%`} left={导航钮(同现 L194-201,换 Button)} showFont extra={行距/边距 SettingRow} />`; +- TxtView 底部:slider 用 `Math.round(total*1000)`,left 区含 导航 + 章计数 + 上一章/下一章(现 L318-346,换 `Button variant=ghost size=sm`),extra = 行距(LINE_HEIGHTS 五档按钮组,当前档 `variant=secondary`)+ 边距(MARGINS 三档,MARGIN_LABEL); +- 正文样式接排版设置:MdView `
` 的 `leading-[1.85]` → `style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}`、`max-w-[46rem]` → `max-w-[46rem]` 保留为 md 上限但受边距档影响?——**裁定**:md 正文限宽沿用 46rem 不接边距档(md 是排版文档,非流式小说),行距接入;TxtView `
` 的 `max-w-[34em]` → `pr.marginMaxW`、`lineHeight: 1.9` → `pr.lineHeight`;
+- A−/A+ 由 ReaderSettings 常驻行提供(showFont),MdView 用 `bumpText`(**裁定**:md 与 txt 共用 text 档位,简化;epub 独立档位在 B3 接 `bumpEpub`)。ReaderSettings 的 bump 需要知道调哪个——组件加可选 prop `onFont?: (d: number) => void`,Text 传 `pr.bumpText`、EPUB 传 `pr.bumpEpub`,未传时 A−/A+ 隐藏。
+
+- [ ] **步骤 12.2:TxtView 接搜索 tab**
+
+- state:`const [query, setQuery] = useState(""); const [hits, setHits] = useState(null);` + 250ms 防抖 effect(query 变化 → setTimeout 重算 `searchChapters(text, chapters, query)`,清理 timer;空 query → hits=null);
+- 传给 ReaderNav:`search={}`(同文件内小组件):顶部 ``(autofocus 用 ref+useEffect,避免 jsx-a11y/no-autofocus error),结果 `
    `:每行按钮 = 章节标题(`chs[h.ch].title`)+ snippet(`splitHighlight` 三段,match 段 ``);点击 → `goChapter(h.ch, intraFrac)` 其中 `intraFrac` 由命中位置估算:`h.pos / chapterText(...).length`(钳制 0..1)→ 现有 goChapter 的 scrollFraction 机制自然滚动到大致位置 + `setNav(null)`;无结果显示「无结果」;hits 为 null(未搜)显示提示文案。 +- 命中处的「短暂高亮」:`goChapter` 后正文无锚点元素可指——**裁定**:以滚动定位为准,不做正文内高亮标记(正文是 `
    ` 纯文本,插标记会破坏选区/进度语义;spec 的「短暂高亮」降级为「定位到大致位置」,如实记录)。
    +- MdView 不加搜索(md 是单文档渲染,无分章结构;范围外)。
    +
    +- [ ] **步骤 12.3:TxtView disable 重估(④ 遗留)**
    +
    +L248 的 `react-hooks/set-state-in-effect` disable:迁移后重估——初始化 effect(L240-257)逻辑不变则保留 disable 与注释;若顺手可改为渲染期重置(chapters 首次非空时按 locator 落位)且行为等价,则修掉并删注释。以保守为先,改动写进报告。
    +
    +- [ ] **步骤 12.4:验证 + Commit(含 B2 changelog)**
    +
    +`npm run check` 绿 + `npm run e2e` 绿 + 手工走查(txt 分章/分卷/进度恢复/书签/搜索跳转/行距边距即时生效/auto 主题联动)。
    +
    +changelog(Added 顶部,双语):
    +
    +```markdown
    +- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
    +- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
    +```
    +
    +```bash
    +git add frontend docs/CHANGELOG_web.md
    +git commit -m "feat(frontend): text reader search + typography, chrome migration (B2)"
    +```
    +
    +### 任务 13:Text e2e fixture + 搜索冒烟
    +
    +**文件:**
    +- 创建:`frontend/e2e/fixtures/e2e-sample.txt`、`frontend/e2e/text-search.spec.ts`
    +
    +- [ ] **步骤 13.1:fixture(精确内容,搜索断言依赖它)**
    +
    +```text
    +第一章 样本
    +
    +这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
    +
    +第二章 落雨
    +
    +雨点敲窗。第二章也有 zebra-xc42 的小写变体。
    +
    +第三章 风止
    +
    + wind  结尾章,无标记词。
    +```
    +
    +- [ ] **步骤 13.2:spec `text-search.spec.ts`**
    +
    +复用 helpers(`adminApi` + 泛化 `ensureSampleBook`——现 helper 写死 cbz fixture;实现时把 `ensureSampleBook(ctx, libName)` 重构为 `ensureBook(ctx, libName, fileName, absPath)` 并保留 `ensureSampleBook` 为 cbz 便捷封装,auth-shelf 不受影响)。流程:上传 e2e-sample.txt 到 `e2e` 库 → 扫描 → waitBook("e2e-sample")——**注意**与 cbz 样本书同名冲突:txt fixture 文件名用 `e2e-sample-txt.txt`(title=e2e-sample-txt)。UI:登录 → 进书 → 导航 → 「搜索」tab → 输入 `zebra-xc42` → 断言结果 ≥2 行(大小写不敏感命中两章)且 snippet 含 `` → 点第二条 → 断言抽屉关闭 + 章计数文本 `2/3`。axe:本页扫描(reader-chrome)保持既有 WAIVERS 策略(B5 统一收严)。
    +
    +- [ ] **步骤 13.3:验证 + Commit**
    +
    +`npm run e2e` 3 spec 全绿;`npm run check` 绿。
    +
    +```bash
    +git add frontend
    +git commit -m "test(frontend): text search e2e with txt fixture (B2)"
    +```
    +
    +---
    +
    +## B3 批次 3:EPUB(迁移 + 排版设置)
    +
    +### 任务 14:EpubReader 迁移 + epubjs themes
    +
    +**文件:**
    +- 修改:`frontend/src/readers/EpubReader.tsx`
    +
    +- [ ] **步骤 14.1:chrome 迁移**
    +
    +底栏(L112-127)→ ReaderSettings:`slider` 不用(EPUB 无可靠全局分数——现状也没有滑条,保持无滑条:ReaderSettings 的 slider prop 改为可选 `slider?`,EPUB 不传,position 也不传;组件内 slider 缺省时不渲染该行);left = 上一页/下一页(`rendRef.current?.prev()/next()`,Button ghost sm)+ 导航钮;showFont(onFont=`pr.bumpEpub`);extra = 行距/边距档位按钮(同 Text 的 SettingRow 模式,档位存 `prefs.epub.lineIdx/marginIdx`)。容器 `bg-white ring-stone-800` → `bg-background ring-border`;加载态 `bg-stone-950 text-stone-500` → `bg-muted text-muted-foreground`;`btn` 重试钮 → `
    +      
    +      {!folded && (
    +        
    + {perDay.map((d) => ( +
    +
    + {d.day.slice(8)} +
    + ))} +
    + )} + + ); +} +``` + +组件测试(fake store 注入):本周文案含时长、连续天数条件渲染、展开/收起(aria-label 翻转 + 柱状图可见性 + localStorage 记忆)、7 根柱。 + +- [ ] **步骤 16.2:Shelf 接线**:`Shelf.tsx` 主内容区顶部(筛选/搜索栏之下、书网格之上)插 ``。 + +- [ ] **步骤 16.3:Reader 会话接线**:`pages/Reader.tsx` 组件顶部 `const stats = useMemo(() => createLocalStats(), []); useReadingSession(stats);`。 + +- [ ] **步骤 16.4:e2e**:auth-shelf 登录后书架段加一行 `await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();`(section aria-label)。 + +- [ ] **步骤 16.5:验证 + Commit(含 B4 changelog)** + +changelog(Added 顶部,双语): + +```markdown +- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open. +- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。 +``` + +```bash +git add frontend docs/CHANGELOG_web.md +git commit -m "feat(frontend): shelf reading stats card + session tracking (B4)" +``` + +--- + +## B5 批次 5:清理与收严 + +### 任务 17:删除 ui.ts / icons.tsx / rd-* 类 + +**文件:** +- 删除:`frontend/src/components/ui.ts`、`frontend/src/components/icons.tsx` +- 修改:`frontend/src/pages/Shelf.tsx`、`frontend/src/index.css`、其余引用点(`grep -rn 'components/ui"\|components/icons' src/` 清点) + +- [ ] **步骤 17.1:使用点迁移**:Shelf.tsx 的 `formatBadge(book.format, "absolute left-1.5 top-1.5")` → ``(任务 8 已建组件);全库 grep `btn\b|btnGhost|formatBadge|IconArrowLeft` 清零后 `git rm` 两文件。 + +- [ ] **步骤 17.2:index.css 清扫**:删除 `.rd-btn/.rd-row/.rd-btn-on/.rd-highlight/.rd-divider/.rd-range`(含伪元素)与 `.rd-sheet` 等全部 rd-* 组件类及其注释;**保留** `[data-rd=...]` token 块、`.rd-surface`(含 ::selection)、`fx-rise`、`md-body`。清扫 `src/**/*.tsx` 内全部 `rd-` class 引用与 stone-*/amber-* 裸色(grep 清单逐个换 token;`--rd-*` var() 引用是合法的保留)。 + +- [ ] **步骤 17.3:验证** + +```bash +grep -rn 'rd-' frontend/src --include='*.tsx' | grep -v 'data-rd\|--rd-' # 预期零输出 +grep -rn 'stone-\|amber-' frontend/src --include='*.tsx' # 预期零输出(读者区) +npm run check && npm run e2e +``` + +- [ ] **步骤 17.4:Commit(含 changelog Changed 条目,双语)** + +```markdown +- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components. +- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。 +``` + +```bash +git add -A frontend docs/CHANGELOG_web.md +git commit -m "refactor(frontend): remove ui.ts/icons.tsx and all rd-* legacy classes (B5)" +``` + +### 任务 18:收严回收 + 总验收 + +**文件:** +- 修改:`frontend/eslint.config.js`、`frontend/e2e/helpers/axe.ts`、(视重估结果)`frontend/src/readers/TextReader.tsx`、`frontend/src/readers/CbzReader.tsx` + +- [ ] **步骤 18.1:eslint 收严**:删除 `eslint.config.js` 的 reader 豁免 override 整块(files 列表 + 3 条 warn 规则 + 注释);`npm run lint` 若有新 error 逐个真修(迁移后的代码应达标;确属误报的规则按全局约束处理并注释)。 + +- [ ] **步骤 18.2:axe WAIVERS 清空**:`helpers/axe.ts` 的 `WAIVERS` 数组清空(保留机制与注释);`npm run e2e` 重扫 login/shelf/reader-chrome/text-search 全部页面——若 reader 页仍有 critical/serious:真修(此时旧 stone chrome 已不存在,剩余多可修);确实无法本期修的**上报控制者裁定**,不得自行回填 WAIVERS。 + +- [ ] **步骤 18.3:行级 disable 重估**:CbzReader 滚动容器 tabIndex 的 `jsx-a11y/no-noninteractive-tabindex` disable——迁移后容器若仍是非交互 div+tabIndex,disable 保留但注释更新(去掉「② 迁移后」字样,写明滚动区可聚焦的通行做法理由);TxtView 的 set-state disable 按任务 12 结论收尾。AuthContext 的 disable(与阅读器无关)不动。 + +- [ ] **步骤 18.4:e2e 补设置面板冒烟**:auth-shelf 或 text-search 内追加:打开「更多阅读设置」→ 改字号/行距一档 → 断言正文 style 变化(`toHaveCSS` 或 style 属性断言)。 + +- [ ] **步骤 18.5:总验收(spec S7 DoD)** + +```bash +npm run check # 含 tsc(e2e/config)/eslint(无豁免)/prettier/vitest/build +npm run e2e # 全部 spec + axe 零豁免红 +grep -rn 'rd-' frontend/src --include='*.tsx' | grep -v 'data-rd\|--rd-' # 零 +ls frontend/src/components/ui.ts frontend/src/components/icons.tsx 2>&1 # 均不存在 +grep -c 'WAIVERS: Waiver\[\] = \[\]' frontend/e2e/helpers/axe.ts # 1(空数组) +git status --short && git log --oneline master..HEAD | wc -l +``` + +手工走查清单(spec S7-2):四阅读器 × 纸/米/夜+auto × 375/768/1280 × light/dark;旧行为回归项:翻页/翻屏、章锁/连读/预读、书签 seek、进度恢复、沉浸收放、编码回退(GBK txt)、分章/分卷。 + +- [ ] **步骤 18.6:Commit + 汇报** + +```bash +git add frontend docs +git commit -m "chore(frontend): tighten eslint/axe gates, remove reader waivers (B5 done, spec ② complete)" +git log --oneline master..HEAD +``` + +向控制者汇报 DoD 结果;控制者走最终整分支审查 + finishing-a-development-branch。 + +--- + +## 自检记录 / Self-check + +- 规格覆盖度:S1→任务 1-6;S2→任务 7-10;S3→任务 11-13;S4→任务 14;S5→任务 15-16;S6→任务 17-18;S7→任务 18.5;S8 范围外无对应任务(正确)。backlog 的 vitest 缝隙→任务 1.3 已纳入。 +- 占位符扫描:新模块全代码;迁移任务给出逐 file:line 映射表与目标结构;PageView 给出完整骨架 + 语义对齐说明(strip 路径引用现有代码不重复贴)。任务 9.2 的 `...`(ci 变化重定位)已用文字语义补全(pending ref 模式对齐现有代码)。 +- 类型一致性:`ReaderPrefs`/`resolveRdTheme`/`useReaderPrefs` API(任务 2)与 reader-settings(任务 5)、各阅读器接线(任务 9/12/14)一致;`StatsStore`(任务 6)与统计卡(任务 16)一致;`SearchHit/splitHighlight`(任务 11)与任务 12 消费一致;`PageMode/viewCount/turnView/spreadPages`(任务 7)与任务 9 PageView 一致;`NavTab "search"`(任务 8 预留)与任务 12 使用一致;`FormatBadge`(任务 8)与任务 17 使用一致。 +- 已知裁定(写死在计划里,防止实现者漂移):md 正文不接边距档;搜索跳转不做正文内高亮(定位到大致位置);EPUB 无滑条(slider prop 可选化);EPUB iframe 用具体色值常量表;onFont prop 区分 text/epub 档位。