Compare commits

..

1 Commits

Author SHA1 Message Date
Huang Xin 5163d01e08 Release portable binaries for Windows (#102) 2025-01-04 18:13:51 +01:00
1814 changed files with 17667 additions and 358741 deletions
-6
View File
@@ -1,6 +0,0 @@
{
"name": "Readest",
// Initialize only the submodules required for Docker builds.
// tauri/tauri-plugins are skipped here since they're only needed for desktop builds.
"postCreateCommand": "git submodule update --init packages/foliate-js packages/simplecc-wasm"
}
-52
View File
@@ -1,52 +0,0 @@
# Dependencies
node_modules
**/node_modules
# Rust build artifacts
target
**/target
# Git
.git
.gitignore
# Build outputs
.next
**/.next
.open-next
**/.open-next
out
**/out
.vercel
**/.vercel
# Local env files
docker/.env
.env*.local
**/.env*.local
# Local credentials and tooling state
*.pem
certs
**/certs
.claude/settings.local.json
**/.claude/settings.local.json
.claude/worktrees
**/.claude/worktrees
.gstack
**/.gstack
.playwright-mcp
**/.playwright-mcp
# IDE
.idea
.vscode
*.swp
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
-3
View File
@@ -1,3 +0,0 @@
[*.{ts,tsx}]
indent_style = space
indent_size = 2
-15
View File
@@ -1,15 +0,0 @@
# These are supported funding model platforms
github: ['readest']
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+6 -10
View File
@@ -1,23 +1,19 @@
---
name: Feature request
about: Share an idea or suggestion
title: 'FR: describing your feature request'
title: 'FR: [a handful of words describing the FR]'
labels: enhancement
assignees: ''
---
**Does your feature request involve difficulty for you to complete a task? Please describe.**
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
**Does your feature request involve difficulty completing a task? Please describe.**
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
**Describe the solution you'd like**
> A clear and concise description of what you'd like to happen.
A clear and concise description of what you'd like to happen.
**Describe alternatives you've considered**
> A clear and concise description of any alternative solutions or features you've considered.
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
> Add any additional context or screenshots about the feature request here.
Add any additional context or screenshots about the feature request here.
+17 -14
View File
@@ -1,7 +1,7 @@
---
name: Report a bug
about: Report a bug or a functional regression
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
labels: ['type: bug']
assignees: ''
---
@@ -11,25 +11,28 @@ assignees: ''
A clear and concise description of what the current behavior is.
Please also add **screenshots** of the existing application.
> **Example:**
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
> [screenshot]
**Example:**
```
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
[screenshot]
```
## Expected behavior
A clear and concise description of what the expected behavior is.
> **Example:**
> The blank square should be transparent (invisible)
**Example:**
```
The blank square should be transparent (invisible)
```
## Technical inputs
Operating System:
**Example:**
Readest Version:
> **Example:**
> Operating System: Android 14 (WebView 135.0)
> Readest Version: 0.9.0
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
> Probably fixable with CSS
```
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
- Probably fixable with CSS
```
+1 -1
View File
@@ -8,6 +8,6 @@ updates:
groups:
github-actions:
patterns:
- '*' # Group all Actions updates into a single larger pull request
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly
-105
View File
@@ -1,105 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: 'CodeQL Advanced'
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '38 20 * * 4'
permissions: read-all
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
- language: rust
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: '/language:${{matrix.language}}'
-191
View File
@@ -1,191 +0,0 @@
name: Publish Docker image
on:
workflow_dispatch:
push:
branches:
- main
release:
types:
- published
concurrency:
group: publish-docker-image-${{ github.event.release.tag_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
permissions:
contents: read
packages: write
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
env:
BUILD_ARGS: |
NEXT_PUBLIC_APP_PLATFORM=web
steps:
- name: Prepare platform pair
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta (for labels)
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository_owner }}/readest
- name: Build and push by digest
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
target: production-stage
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ env.BUILD_ARGS }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }}
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }},mode=max
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/readest,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: build
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Detect Docker Hub credentials
id: dockerhub
run: |
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to Docker Hub
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract image metadata (GHCR only)
id: meta-ghcr
if: steps.dockerhub.outputs.enabled != 'true'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ghcr.io/${{ github.repository_owner }}/readest
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
type=sha,prefix=sha-
- name: Extract image metadata (GHCR + Docker Hub)
id: meta-all
if: steps.dockerhub.outputs.enabled == 'true'
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
ghcr.io/${{ github.repository_owner }}/readest
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest
tags: |
type=raw,value=main,enable={{is_default_branch}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
type=sha,prefix=sha-
- name: Create manifest list and push (GHCR only)
if: steps.dockerhub.outputs.enabled != 'true'
working-directory: /tmp/digests
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-ghcr.outputs.json }}
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
- name: Create manifest list and push (GHCR + Docker Hub)
if: steps.dockerhub.outputs.enabled == 'true'
working-directory: /tmp/digests
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-all.outputs.json }}
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
- name: Published image summary
run: |
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.dockerhub.outputs.enabled }}" == "true" ]; then
echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
else
echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
fi
echo '```' >> "$GITHUB_STEP_SUMMARY"
+12 -223
View File
@@ -1,246 +1,35 @@
name: PR checks
name: Build Web Application on Pull Request
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
contents: write
pull-requests: write
jobs:
rust_lint:
runs-on: ubuntu-latest
env:
RUSTFLAGS: '-C target-cpu=skylake'
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Install minimal stable with clippy and rustfmt
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-rust-lint-${{ runner.os }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
- name: Format check
working-directory: apps/readest-app/src-tauri
run: cargo fmt --check
- name: Clippy Check
working-directory: apps/readest-app/src-tauri
run: cargo clippy -p Readest --no-deps -- -D warnings
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@v4
with:
version: 9.15.1
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
# Turbopack persists its build cache here (turbopackFileSystemCacheForBuild).
# The key is deterministic (no commit SHA) so every PR restores the same
# entry — in practice the one `main` last saved, which is the only cache
# sibling PRs can all see.
- name: cache Turbopack build cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: apps/readest-app/.next/cache
key: turbo-build-web-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
turbo-build-web-${{ runner.os }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- name: install LuaJIT (for koplugin lint)
run: sudo apt-get update && sudo apt-get install -y luajit
- name: run format check
run: |
pnpm format:check || (pnpm format && git diff && exit 1)
- name: run lint
working-directory: apps/readest-app
run: |
pnpm lint
- name: build the web app
working-directory: apps/readest-app
run: |
pnpm build-web && pnpm check:all
- name: cache playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: install playwright browsers
working-directory: apps/readest-app
run: |
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
- name: run web e2e tests
id: web_e2e
working-directory: apps/readest-app
run: pnpm test:e2e:web
- name: upload e2e report
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: apps/readest-app/playwright-report/
retention-days: 7
test_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
node-version: 22
cache: pnpm
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
pnpm install && pnpm setup-pdfjs
- name: cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-test-web-${{ runner.os }}
- name: install LuaJIT + busted (for koplugin tests)
run: |
sudo apt-get update
# luajit — pnpm test:lua
# luarocks/libsqlite3-dev — required to build lsqlite3complete
sudo apt-get install -y luajit luarocks libsqlite3-dev
# Install busted + the SQLite binding the LibraryStore specs use,
# both pinned to Lua 5.1 (LuaJIT-compatible). System-wide install
# so `luarocks --lua-version=5.1 path` (sourced by
# scripts/test-koplugin.mjs) picks them up.
sudo luarocks --lua-version=5.1 install busted
sudo luarocks --lua-version=5.1 install lsqlite3complete
- name: cache playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: install playwright browsers
- name: build the web App
working-directory: apps/readest-app
run: |
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
- name: run web tests
working-directory: apps/readest-app
run: pnpm test:pr:web
- name: run koplugin tests
working-directory: apps/readest-app
run: pnpm test:lua
build_tauri_app:
runs-on: ubuntu-latest
env:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
# The tauri tests run `next dev`, whose Turbopack cache lives in
# `.next/dev/cache` (a different path from the `next build` cache).
- name: cache Turbopack dev cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: apps/readest-app/.next/dev/cache
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
turbo-dev-tauri-${{ runner.os }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- name: setup sccache
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: tauri-cargo
cache-all-crates: 'true'
- name: Cache apt packages
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-tauri-${{ runner.os }}
- name: install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb
- name: run tauri tests
working-directory: apps/readest-app
run: xvfb-run pnpm test:pr:tauri
pnpm build-web
+59 -289
View File
@@ -5,28 +5,25 @@ on:
release:
types: [published]
permissions: read-all
jobs:
get-release:
permissions:
contents: read
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.get-release.outputs.release_id }}
release_tag: ${{ steps.get-release.outputs.release_tag }}
release_note: ${{ steps.get-release-notes.outputs.release_note }}
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
- name: get version
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
- name: get release
id: get-release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@v7
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
@@ -37,7 +34,7 @@ jobs:
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
@@ -47,77 +44,8 @@ jobs:
const notes = releaseNotes.notes || [];
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
console.log('Formatted release note:', releaseNote);
core.setOutput('release_version', version);
core.setOutput('release_note', releaseNote);
update-release:
permissions:
contents: write
runs-on: ubuntu-latest
needs: get-release
steps:
- name: update release
id: update-release
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const { data } = await github.rest.repos.generateReleaseNotes({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: process.env.release_tag,
})
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
build-koreader-plugin:
needs: get-release
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: create KOReader plugin zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
version=${{ needs.get-release.outputs.release_version }}
plugin_zip="Readest-${version}-1.koplugin.zip"
meta_file="apps/readest.koplugin/_meta.lua"
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
# Exclude dev-only artifacts from the published plugin zip:
# scripts/ — i18n + build helpers
# docs/ — design notes
# spec/ — busted test suite
# .busted — busted runner config
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
# for local builds.
cd apps
zip -r ../${plugin_zip} readest.koplugin \
-x 'readest.koplugin/scripts/*' \
'readest.koplugin/docs/*' \
'readest.koplugin/spec/*' \
'readest.koplugin/.busted'
cd ..
echo "Uploading ${plugin_zip} to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
build-tauri:
needs: get-release
permissions:
@@ -126,182 +54,78 @@ jobs:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
release: android
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- os: ubuntu-22.04
release: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
release: macos
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc --bundles nsis'
args: '--target x86_64-pc-windows-msvc'
- os: windows-latest
release: windows
arch: aarch64
rust_target: aarch64-pc-windows-msvc
args: '--target aarch64-pc-windows-msvc --bundles nsis'
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
uses: pnpm/action-setup@v4
with:
version: 9.14.4
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: 24
node-version: 22
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK (for Android build only)
if: matrix.config.release == 'android'
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
- name: install NDK (for Android build only)
if: matrix.config.release == 'android'
run: sdkmanager "ndk;28.2.13676358"
- name: install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
run: pnpm install
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: copy pdfjs-dist to public directory
run: pnpm --filter @readest/readest-app setup-pdfjs
- name: install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
- name: install dependencies (ubuntu only - armhf specific)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
run: |
sudo dpkg --add-architecture armhf
sudo apt-get update
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
workspaces: apps/readest-app/src-tauri -> target
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: build and upload Android apks
if: matrix.config.release == 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
- name: copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app/.env.local
- name: install dependencies (ubuntu only)
if: matrix.config.os == 'ubuntu-22.04'
run: |
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
pushd src-tauri/gen/android
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
popd
version=${{ needs.get-release.outputs.release_version }}
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
universial_apk=Readest_${version}_universal.apk
arm64_apk=Readest_${version}_arm64.apk
pnpm tauri android build
cp ${apk_path}/app-universal-release.apk $universial_apk
pnpm tauri android build -t aarch64
cp ${apk_path}/app-universal-release.apk $arm64_apk
echo "Uploading $universial_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
echo "Uploading $arm64_apk to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
echo "Uploading signatures to GitHub release"
pnpm tauri signer sign $universial_apk
pnpm tauri signer sign $arm64_apk
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
- name: download and update latest.json for Android release
if: matrix.config.release == 'android'
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
universial_sig=$(cat Readest_${version}_universal.apk.sig)
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
jq --arg url "$universial_apk_url" \
--arg sig "$universial_sig" \
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
jq --arg url "$arm64_apk_url" \
--arg sig "$arm64_sig" \
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
- name: Override tauri-cli with custom AppImage format (Linux)
if: matrix.config.release == 'linux'
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
@@ -310,107 +134,53 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
with:
projectPath: apps/readest-app
# On Linux, build with the Rust `cargo tauri` CLI installed in the
# step above so the new (truly-portable AppImage) bundler is used.
# Without this, tauri-action falls back to the npm @tauri-apps/cli.
tauriScript: ${{ matrix.config.release == 'linux' && 'cargo tauri' || '' }}
releaseId: ${{ needs.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
- name: upload release notes to GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Uploading release notes to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
- name: build and upload portable binaries (Windows only)
- name: upload portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
shell: bash
run: |
echo "Building Portable Binaries"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build ${{ matrix.config.args }}
popd
echo "Uploading Portable Binaries"
arch=${{ matrix.config.arch }}
version=${{ needs.get-release.outputs.release_version }}
version=$PACKAGE_VERSION
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
zip_name="Readest_${version}_x64-portable.zip"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
zip_name="Readest_${version}_arm64-portable.zip"
else
echo "Unknown architecture: $arch"
exit 1
fi
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows won't download zip files that contain exe files
# so upload the exe files instead. This is totally stupid.
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
cp $exe_file $bin_file
zip -j $zip_name apps/readest-app/src-tauri/target/release/readest.exe
echo "Uploading $zip_name to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $zip_name --clobber
echo "Uploading $bin_file to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
echo "Signing portable binary"
pushd apps/readest-app/
pnpm tauri signer sign "../../$bin_file"
popd
echo "Uploading signature to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
- name: download and update latest.json for Windows portable release
if: matrix.config.os == 'windows-latest'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
version=${{ needs.get-release.outputs.release_version }}
arch=${{ matrix.config.arch }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
platform_key="windows-x86_64-portable"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
platform_key="windows-aarch64-portable"
else
echo "Unknown architecture: $arch"
exit 1
fi
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
portable_sig=$(cat $bin_file.sig)
jq --arg url "$portable_url" \
--arg sig "$portable_sig" \
--arg key "$platform_key" \
'.platforms[$key] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
echo "Uploading updated latest.json to GitHub release"
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
upload-to-r2:
needs: [get-release, build-tauri]
update-release:
permissions:
contents: read
uses: ./.github/workflows/upload-to-r2.yml
with:
tag: ${{ needs.get-release.outputs.release_tag }}
secrets: inherit
contents: write
runs-on: ubuntu-latest
needs: [get-release, build-tauri]
steps:
- name: update release
id: update-release
uses: actions/github-script@v7
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_note: ${{ needs.get-release.outputs.release_note }}
with:
script: |
const body = `## Release Highlight\n${process.env.release_note}`;
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
body: body,
draft: false,
prerelease: false
})
-78
View File
@@ -1,78 +0,0 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: '26 4 * * 3'
push:
branches: [ "main" ]
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: "Checkout code"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
# file_mode: git
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: SARIF file
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: results.sarif
-73
View File
@@ -1,73 +0,0 @@
name: Upload Release Assets to R2
on:
workflow_call:
inputs:
tag:
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: 'Release tag name (e.g., v1.2.3)'
required: true
type: string
permissions: read-all
jobs:
upload-to-r2:
runs-on: ubuntu-latest
permissions:
contents: read
timeout-minutes: 3
strategy:
fail-fast: false
env:
RELEASE_R2_BUCKET: readest-releases
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
steps:
- name: Download release assets
run: |
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install rclone
run: |
sudo apt-get update
sudo apt-get install -y rclone
- name: Configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
- name: Modify latest.json download URLs
run: |
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
READEST_BASE_URL="https://download.readest.com/releases"
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
- name: Upload to R2
run: |
mkdir releases
mv ./release-assets/latest.json releases
mv ./release-assets/release-notes.json releases
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
- name: Upload successful
if: success()
run: echo "Upload completed successfully"
+6 -6
View File
@@ -4,20 +4,20 @@ on:
branches:
- main
permissions:
contents: read
contents: write
deployments: write
pull-requests: write
jobs:
build_and_deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
with:
submodules: 'true'
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
vercel-args: '--prod'
vercel-org-id: ${{ secrets.ORG_ID}}
vercel-project-id: ${{ secrets.PROJECT_ID}}
-19
View File
@@ -1,5 +1,4 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
docker/.env
# dependencies
/node_modules
@@ -36,21 +35,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# Rust build
target
fastlane/report.xml
fastlane/metadata/android/en-US/changelogs
*.koplugin.zip
# nix
result*
.playwright-mcp/
.gstack
.claude/worktrees
.claude/settings.local.json
+2 -23
View File
@@ -1,27 +1,6 @@
[submodule "packages/foliate-js"]
path = packages/foliate-js
url = https://github.com/readest/foliate-js.git
url = https://github.com/chrox/foliate-js.git
[submodule "packages/tauri"]
path = packages/tauri
url = https://github.com/readest/tauri.git
[submodule "packages/tauri-plugins"]
path = packages/tauri-plugins
url = https://github.com/readest/tauri-plugins-workspace.git
[submodule "packages/simplecc-wasm"]
path = packages/simplecc-wasm
url = https://github.com/readest/simplecc-wasm.git
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"]
path = apps/readest-app/src-tauri/plugins/tauri-plugin-turso
url = https://github.com/readest/tauri-plugin-turso.git
[submodule "apps/readest-app/.claude/skills/gstack"]
path = apps/readest-app/.claude/skills/gstack
url = https://github.com/garrytan/gstack.git
[submodule "packages/qcms"]
path = packages/qcms
url = https://github.com/mozilla/pdf.js.qcms.git
[submodule "packages/js-mdict"]
path = packages/js-mdict
url = https://github.com/readest/js-mdict.git
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade"]
path = apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade
url = https://github.com/readest/tauri-plugin-webview-upgrade.git
url = https://github.com/chrox/tauri.git
-1
View File
@@ -1 +0,0 @@
pnpm exec lint-staged
-3
View File
@@ -1,3 +0,0 @@
pnpm -C apps/readest-app format:check
pnpm -C apps/readest-app lint
pnpm -C apps/readest-app test
+1
View File
@@ -0,0 +1 @@
packages/foliate-js/
+9
View File
@@ -0,0 +1,9 @@
{
"trailingComma": "all",
"printWidth": 100,
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"plugins": ["prettier-plugin-tailwindcss"]
}
-8
View File
@@ -1,8 +0,0 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-next",
"dbaeumer.vscode-eslint",
"biomejs.biome",
"rust-lang.rust-analyzer"
]
}
+1 -26
View File
@@ -4,29 +4,4 @@
"packages/tauri/Cargo.toml",
"apps/readest-app/src-tauri/Cargo.toml"
],
// "editor.formatOnSave": true, // uncomment to add format on save
"typescript.inlayHints.parameterNames.enabled": "all",
"typescript.inlayHints.variableTypes.enabled": true,
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
"typescript.inlayHints.enumMemberValues.enabled": true,
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
}
+7 -7
View File
@@ -3,14 +3,14 @@
When contributing to `Readest`, whether on GitHub or in other community spaces:
- Be respectful, civil, and open-minded.
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
## How to Contribute
### Prerequisites
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
#### 1. Clone the Repository
```bash
git clone https://github.com/readest/readest.git
git clone https://github.com/chrox/readest.git
cd readest
git submodule update --init --recursive
```
@@ -43,8 +43,8 @@ git submodule update --init --recursive
```bash
# might need to rerun this when code is updated
pnpm install
# copy vendors dist libs to public directory
pnpm --filter @readest/readest-app setup-vendors
# copy pdfjs-dist to Next.js public directory
pnpm --filter @readest/readest-app setup-pdfjs
```
#### 3. Verify Dependencies Installation
@@ -75,7 +75,7 @@ Now you're all setup and can start implementing your changes.
### Implement your changes
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
| Command | Description |
| ---------------- | -------------------------------------------------- |
@@ -86,7 +86,7 @@ Recommended Visual Studio Code plugins for development:
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
- VS Code ESLint extension (dbaeumer.vscode-eslint)
- Biome - Code formatter and linter (biomejs.biome)
- Prettier - Code formatter (esbenp.prettier-vscode)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
Generated
-10581
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
[workspace]
members = [
"apps/readest-app/src-tauri",
"packages/tauri/crates/tauri",
"packages/tauri-plugins/plugins/fs"
]
exclude = [
"packages/qcms"
]
resolver = "2"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tracing = "0.1"
log = "0.4"
tauri = { version = "2", default-features = false }
tauri-build = "2"
tauri-plugin = "2"
tauri-utils = "2"
schemars = "0.8"
serde_json = "1"
thiserror = "2"
glob = "0.3"
zbus = "5.9"
dunce = "1"
url = "2"
tar = "0.4.45"
nix = "0.20.2"
glib = "0.20.0"
[workspace.package]
authors = ["Bilingify LLC"]
homepage = "https://readest.com"
license = "AGPL-3.0"
repository = "https://github.com/readest/readest"
categories = []
edition = "2021"
rust-version = "1.77.2"
[patch.crates-io]
tauri = { path = "packages/tauri/crates/tauri" }
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
-59
View File
@@ -1,59 +0,0 @@
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS dependencies
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/readest-app/package.json ./apps/readest-app/
COPY patches/ ./patches/
COPY packages/ ./packages/
RUN --mount=type=cache,id=pnpm,sharing=locked,target=/pnpm/store pnpm install --frozen-lockfile
RUN test -f packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css \
&& test -d packages/simplecc-wasm/dist/web \
|| { printf '\nERROR: Required git submodules are not initialized in the source directory.\nEnsure submodules are initialized before running docker build.\nRun: git submodule update --init packages/foliate-js packages/simplecc-wasm\n\n'; exit 1; }
RUN pnpm --filter @readest/readest-app setup-vendors
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS development-stage
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
COPY --from=dependencies /app /app
COPY . .
WORKDIR /app/apps/readest-app
EXPOSE 3000
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS build
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ARG NEXT_PUBLIC_APP_PLATFORM
ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
COPY --from=dependencies /app/node_modules /app/node_modules
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
COPY . .
WORKDIR /app/apps/readest-app
RUN pnpm build-web
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@11.1.1 --activate
WORKDIR /app
COPY --from=build /app /app
WORKDIR /app/apps/readest-app
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
EXPOSE 3000
+48 -201
View File
@@ -5,22 +5,17 @@
<h1>Readest</h1>
<br>
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
[![Website][badge-website]][link-website]
[![Web App][badge-web-app]][link-web-readest]
[![OS][badge-platforms]][link-website]
[![][badge-discord]][link-discord]
<br>
[![Discord][badge-discord]][link-discord]
[![Reddit][badge-reddit]][link-reddit]
[![AGPL Licence][badge-license]](LICENSE)
[![Language Coverage][badge-language-coverage]][link-locales]
[![Donate][badge-donate]][link-donate]
[![Latest release][badge-release]][link-gh-releases]
[![Last commit][badge-last-commit]][link-gh-commits]
[![Commits][badge-commit-activity]][link-gh-pulse]
[![][badge-hellogithub]][link-hellogithub]
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
</div>
@@ -30,14 +25,13 @@
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#support">Support</a> •
<a href="#contributors">Contributors</a> •
<a href="#license">License</a>
</p>
<div align="center">
<a href="https://readest.com" target="_blank">
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
</a>
</div>
@@ -45,38 +39,38 @@
<div align="left">✅ Implemented</div>
| **Feature** | **Description** | **Status** |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
| **Feature** | **Description** | **Status** |
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **Feature** | **Description** | **Priority** |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
@@ -84,35 +78,24 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
![Annotations](./data/screenshots/annotations.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![DeepL](./data/screenshots/deepl.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Theming Dark Mode](./data/screenshots/theming_dark_mode.png)
![Themeing Dark Mode](./data/screenshots/theming_dark_mode.png)
---
## Downloads
### Mobile Apps
The Readest app is available for download! 🥳 🚀
<div align="center">
<a href="https://apps.apple.com/app/id6738622779">
<img alt="Download on the App Store" src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" style="height: 50px;" /></a>&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://play.google.com/store/apps/details?id=com.bilingify.readest">
<img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" style="height: 50px;" /></a>
</div>
### Platform-Specific Downloads
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Linux users can also install [Readest on Flathub][link-flathub].
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
- Web: Visit [https://web.readest.com][link-web-readest].
- iOS / Android: coming soon 👀
## Requirements
@@ -122,8 +105,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
```bash
nvm install v24
nvm use v24
nvm install v22
nvm use v22
npm install -g pnpm
rustup update
```
@@ -137,16 +120,16 @@ To get started with Readest, follow these steps to clone and build the project.
```bash
git clone https://github.com/readest/readest.git
cd readest
git submodule update --init --recursive
```
### 2. Install Dependencies
```bash
# might need to rerun this when code is updated
git submodule update --init --recursive
pnpm install
# copy vendors dist libs to public directory
pnpm --filter @readest/readest-app setup-vendors
# copy pdfjs-dist to Next.js public directory
pnpm --filter @readest/readest-app setup-pdfjs
```
### 3. Verify Dependencies Installation
@@ -164,169 +147,45 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
### 4. Build for Development
```bash
# Start development for the Tauri app
pnpm tauri dev
# or start development for the Web app
pnpm dev-web
# preview with OpenNext build for the Web app
pnpm preview
```
For Android:
```bash
# Initialize the Android environment (run once)
rm apps/readest-app/src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout apps/readest-app/src-tauri/gen/android
pnpm tauri android dev
# or if you want to dev on a real device
pnpm tauri android dev --host
```
For iOS:
```bash
# Set up the iOS environment (run once)
pnpm tauri ios init
pnpm tauri icon ../../data/icons/readest-book.png
pnpm tauri ios dev
# or if you want to dev on a real device
pnpm tauri ios dev --host
```
### 5. Build for Production
```bash
pnpm tauri build
pnpm tauri android build
pnpm tauri ios build
```
Please refer to our release script if you experience any issues:
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
### 6. Setup dev environment with Nix
If you have Nix installed, you can leverage flake to enter a development shell
with all the necessary dependencies:
```bash
nix develop ./ops # enter a dev shell for the web app
nix develop ./ops#ios # enter a dev shell for the ios app
nix develop ./ops#android # enter a dev shell for the android app
```
### 7. More information
### 6. More information
Please check the [wiki][link-gh-wiki] of this project for more information on development.
## Troubleshooting
### 1. Readest Wont Launch on Windows (Missing Edge WebView2 Runtime)
**Symptom**
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
- This can affect both the standard installer and the portable version.
**Cause**
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
**How to Fix**
1. Check if WebView2 is installed
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
2. Install or Update WebView2
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
- If you prefer an offline installer, download the offline package and run it as an Administrator.
3. Re-run Readest
- After installing/updating WebView2, launch readest.exe again.
- If you still encounter problems, reboot your PC and try again.
**Additional Tips**
- If reinstalling once doesnt work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
- Verify your Windows installation has the latest updates from Microsoft.
**Still Stuck?**
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps youve taken.
### 2. AppImage Launches but Only Shows a Taskbar Icon
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
You might see logs such as:
```
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the systems EGL / Wayland environment.
**Workaround 1: Launch with LD_PRELOAD (recommended)**
You can preload the system Wayland client library before launching the AppImage:
```
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
```
This workaround has been confirmed to resolve the issue on affected systems.
**Workaround 2: Use the Flatpak Version**
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
<img width="200" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
## Support
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
### Sponsors
<p align="center">
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
</a>
</p>
## License
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
The following libraries and frameworks are used in this software:
The following JavaScript libraries are bundled in this software:
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
- [react](https://github.com/facebook/react), which is MIT licensed.
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
---
@@ -336,19 +195,12 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
[badge-donate]: https://donate.readest.com/badge.svg
[badge-deepwiki]: https://deepwiki.com/badge.svg
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
[badge-language-coverage]: https://img.shields.io/badge/coverage-53%25%20population%20🌍-green
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
[link-web-readest]: https://web.readest.com
[link-gh-releases]: https://github.com/readest/readest/releases
[link-gh-commits]: https://github.com/readest/readest/commits/main
@@ -357,8 +209,3 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
[link-discord]: https://discord.gg/gntyVNk3BJ
[link-parallel-read]: https://readest.com/#parallel-read
[link-koreader]: https://github.com/koreader/koreader
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
[link-deepwiki]: https://deepwiki.com/readest/readest
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
[link-reddit]: https://reddit.com/r/readest/
-142
View File
@@ -1,142 +0,0 @@
# Security Policy
## Threat Model
### Overview
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
### Assets
| Asset | Description |
| ------------------------------ | ------------------------------------------------------------------------------------ |
| Ebook files | User-uploaded EPUB, MOBI, PDF, and other formats stored locally and in cloud storage |
| Reading progress & annotations | Highlights, bookmarks, and notes synced across devices |
| User credentials | Authentication tokens and session data for cloud sync |
| User preferences & settings | Reading preferences, custom fonts, theme configurations |
| External API keys | Translation service credentials (DeepL, Yandex) configured by users |
### Threat Actors
| Actor | Motivation |
| ----------------------- | ---------------------------------------------------------- |
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
| Unauthorized user | Access another user's synced library or annotations |
### Attack Surfaces & Mitigations
#### 1. Ebook File Parsing
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
#### 2. Cloud Sync & Authentication
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
#### 3. OPDS / External Catalog Integration
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
#### 4. Rendered HTML/JS in Ebook Content
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
#### 5. Supply Chain
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
#### 6. Desktop Native Code (Tauri)
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
### Out of Scope
- Vulnerabilities in user's operating system or browser outside of Readest's control
- Physical access attacks to a user's device
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
## Supported Versions
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
| Version | Supported |
| ------- | ------------------ |
| 0.10.x | :white_check_mark: |
| < 0.10 | :x: |
## Reporting a Vulnerability
Please report suspected vulnerabilities privately. Do not open a public GitHub
issue or discussion for security-sensitive reports.
Use GitHub's private vulnerability reporting for this repository:
<https://github.com/readest/readest/security/advisories/new>
When submitting a report, include:
- A clear description of the issue and the affected component
- Steps to reproduce, proof of concept, or a minimal test case
- The versions, platforms, or environments you tested
- Any suggested remediation or mitigating details, if available
What to expect after you report:
- We will aim to acknowledge receipt within 3 business days.
- We may contact you for additional details, reproduction steps, or validation.
- If the report is accepted, we will work on a fix and coordinate disclosure.
- If the report is declined, we will explain why, for example if the behavior is
expected, unsupported, or not reproducible.
Please keep vulnerability details private until a fix is available and the
maintainers have approved disclosure.
## Incident Response Plan
When a security vulnerability is confirmed, we follow this process:
### 1. Triage (Day 12)
- Assign a severity level (Critical / High / Medium / Low) based on impact and exploitability.
- Identify affected versions, components, and users.
- Assign an owner responsible for coordinating the response.
### 2. Containment (Day 13)
- Assess whether an immediate mitigation or workaround can be published.
- Limit further exposure where possible (e.g., disable affected features, update dependencies).
### 3. Remediation (Day 314, depending on severity)
- Develop and internally review a fix.
- Validate the fix does not introduce regressions.
- Prepare a patched release and update changelog.
### 4. Disclosure & Release
- Coordinate disclosure timing with the reporter.
- Publish a GitHub Security Advisory with CVE if applicable.
- Release the patched version and notify users via release notes.
### 5. Post-Incident Review
- Document the root cause, timeline, and resolution.
- Update processes or controls to prevent recurrence.
### Severity Definitions
| Severity | Description |
| -------- | --------------------------------------------------------------------- |
| Critical | Remote code execution, full data compromise, or authentication bypass |
| High | Significant data exposure, privilege escalation, or denial of service |
| Medium | Limited data exposure or functionality disruption |
| Low | Minor issues with minimal security impact |
-1
View File
@@ -1 +0,0 @@
../.claude/memory
-1
View File
@@ -1 +0,0 @@
../.claude/plans
-1
View File
@@ -1 +0,0 @@
../.claude/rules
-59
View File
@@ -1,59 +0,0 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
## Paginator Scroll Knowledge
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
## Sync Notes
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
## Feature Notes
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
## Patterns
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
## Architecture Notes
- foliate-js is a git submodule at `packages/foliate-js/`
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
## Workflow
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
@@ -1,90 +0,0 @@
# Annotator & Reader Fixes Reference
## Annotation System Architecture
### Key Components
- `Annotator.tsx` - Annotation lifecycle, popup display, style/color management
- `AnnotationRangeEditor.tsx` - Drag handles for adjusting selection range
- `MagnifierLoupe.tsx` - Magnifying glass during handle drag (mobile only)
- `useTextSelector.ts` - Text selection detection and processing
- `useAnnotationEditor.ts` - Editing existing annotations
- `useInstantAnnotation.ts` - Creating new annotations on selection
### Highlight Rendering
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
- `Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
### Multiview Overlayer Pitfalls
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
## Fix History
| Issue | Problem | Root Cause | Fix |
|-------|---------|------------|-----|
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
| #3225 | Android tools unresponsive | Premature `makeSelection()` call | Remove premature re-selection in Android path |
## Common Annotation Bugs
### Selection Issues
- **Word snapping**: Uses `Intl.Segmenter` with `granularity: 'word'` to snap selection to word boundaries
- **Android re-selection**: Don't call `makeSelection(sel, index, true)` immediately on pointer-up; let the popup flow complete
- **Range editor handles**: Remove `initializedRef` guards that prevent re-computation when switching annotations
### Color/Style Issues
- **Custom colors in sidebar**: Use inline `style={{ backgroundColor: 'color-mix(...)' }}` not Tailwind classes
- **Style synchronization**: Consolidate `selectedStyle` and `selectedColor` into one `useEffect`
- **Switching annotations**: Must call `setShowAnnotPopup(false)` and `setEditingAnnotation(null)` before setting up new annotation
## Reader/Content Fixes
### Progress Display
- Use physical `view.renderer.page` and `view.renderer.pages` for page counts (#3213, #3200)
- Last page shows 100% by fixing boundary condition (#3383)
- FB2 subsections need special handling for progress (#3136)
### Translation View (#3078)
- Problem: Page jumps back during full-text translation
- Root cause: DOM mutations from sequential translation insertions cause paginator relayout
- Fix: Batch DOM updates with 50ms timer, use bounded concurrent queue (max 5), show loading overlay
### TOC Navigation (#3124)
- Problem: Expanding TOC chapter scrolls back to current chapter
- Fix: Only scroll-into-view on navigation, not on expand/collapse
## Accessibility (a11y) Fixes
### Screen Reader (TalkBack) Support
- **Page indicator updates** (#2276): Add focus handlers on `<p>` elements that call `view.goTo(cfi)` to update position
- **Navigation buttons** (#3036): Always show prev/next buttons when screen reader active; `PageNavigationButtons.tsx`
- **Dropdown menus** (#3035): Use `DropdownContext` with overlay dismiss instead of blur-based closing
### Dropdown Architecture for a11y
- `DropdownContext` (`src/context/DropdownContext.tsx`) manages which dropdown is open globally
- Uses `useId()` for unique identification
- One dropdown open at a time
- `<Overlay>` for dismissal (tap/click outside) instead of `onBlur`
- `<details>` element with `open={isOpen}` for semantic structure
- No auto-focus-first-item (conflicts with TalkBack)
## E-ink Readability
- Use `not-eink:` Tailwind variant for colors and opacity (#3258)
- Don't use `text-primary` (blue) or low opacity on e-ink
- Highlights use foreground color in dark mode e-ink (#3299)
## Key Utility Functions
- `snapRangeToWords()` in `src/utils/sel.ts` - Word boundary snapping
- `handleAccessibilityEvents()` in `src/utils/a11y.ts` - Screen reader focus handling
- `color-mix()` CSS function for custom highlight colors with opacity
@@ -1,133 +0,0 @@
# Bug Fixing Patterns & Strategies
## Common Root Cause Categories
### 1. Overly Broad CSS Selectors
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
**Examples:**
- `hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
- `p img { mix-blend-mode }` applied to block images, not just inline (#3112)
- `svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
### 2. Conditional vs Unconditional Style Overrides
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
**Examples:**
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
### 3. Missing EPUB Stylesheet Transformations
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
**Examples:**
- `user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
- `font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
### 4. Stale State / Refs Not Reset
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
**Examples:**
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
- `initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
- `view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
### 5. Platform API Differences
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
**Examples:**
- `navigator.getGamepads()` returns null on older Android WebView (#3245)
- `CompressionStream` unavailable on some Android versions (#3255)
- `btoa()` throws on non-ASCII characters (#3436)
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
- `document.startViewTransition()` crashes on Linux
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
### 6. Safe Area Inset Issues
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
**Examples:**
- Zoom controls behind status bar (#3426)
- Android navigation bar overlap (#3466)
- iPad sidebar insets incorrect (#3395)
- Reader page layout jump after system UI change (#3469)
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
### 7. Z-Index Layering Issues
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
**Examples:**
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
- Page nav buttons behind TTS control (#3184)
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
### 8. Event Handling Race Conditions
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
**Examples:**
- macOS context menu steals pointer event loop (#3324) -> 100ms setTimeout delay
- Traffic light buttons flicker due to timeout race (#3488, #3129)
- Android tool buttons unresponsive due to premature re-selection (#3225)
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
### 9. foliate-js Rendering Issues
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
**Examples:**
- Image size not constrained in double-page mode (#3432)
- Background not shown in scrolled mode (#3344)
- Section content cached incorrectly after mode switch (#3242, #3206)
- Swipe sensitivity too low for non-animated paging (#3310)
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
### 10. Progress/Navigation Calculation Errors
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
**Examples:**
- Progress shows 99.9% at last page (#3383) -> boundary condition
- Pages left shows estimated instead of physical count (#3213, #3200)
- FB2 subsection progress wrong (#3136) -> nested structure not handled
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
### 11. Debounced State Stale on User-Initiated Layout Change
**Pattern:** A scroll/resize handler is debounced for performance, but during the debounce window any code path that re-runs layout based on saved state (e.g. `#anchor`, `#primaryIndex`) sees stale values.
**Example:**
- Scrolled-mode toggle reverted to previous chapter (#3987): the paginator's scroll handler is debounced 250 ms, so toggling `flow=scrolled → flow=paginated` within that window made `render() → scrollToAnchor(#anchor)` restore the anchor from before the user scrolled into the next section. Both `#anchor` and `#primaryIndex` were stale together, sending the position back.
**Fix Strategy:** When an external trigger forces a re-render (here, `setAttribute('flow', ...)`), flush the debounced state synchronously *before* changing the layout. In paginator.js this means overriding `setAttribute` and calling `#detectPrimaryView()` + `#getVisibleRange()` while `this.scrolled` is still true.
### 12. Multiview Paginator Side Effects
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
**Examples:**
- `load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
- `MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
### 13. Whole-Field-Synced Flag Reaches an Unsupported Platform
**Pattern:** A setting is whole-field synced across devices (e.g. `dictionarySettings.providerEnabled`), so a flag enabled on one platform arrives `true` on a platform where that feature isn't supported. The lookup/runtime path correctly gates on platform support, but a *secondary consumer* (usually UI gating) reads the raw synced flag and misbehaves.
**Example:**
- System Dictionary enabled on macOS synced to web → web's `CustomDictionaries.tsx` locked all other dictionary toggles read-only (`lockedBySystem`) even though System Dictionary is hidden + a no-op there. The annotator's lookup path used the platform-gated `isSystemDictionaryEnabled(settings)` (registry.ts, gates on `isSystemDictionarySupported()`), but the settings UI compared the raw `providerEnabled[systemDictionary] === true`.
**Fix Strategy:** Every consumer of a synced flag for a platform-specific feature must route through the *same* platform-aware gate the runtime uses — not the raw `providerEnabled[...]`/setting value. Here: `lockedBySystem = isSystemDictionaryEnabled(settings) && ...`. Search for other readers of the raw flag when fixing one.
## Debugging Workflow
1. **Identify the category** from the issue description
2. **Check `style.ts`** first for any CSS-related visual bugs
3. **Check foliate-js** for rendering/layout bugs
4. **Check platform-specific code** for mobile/desktop differences
5. **Write a failing test** before implementing the fix
6. **Test in both paginated and scrolled modes** for layout changes
7. **Test on multiple platforms** for any UI change
8. **Run `pnpm build-check`** before submitting
@@ -1,73 +0,0 @@
---
name: Cloudflare Workers WebSocket
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
type: project
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
---
# Cloudflare Workers WebSocket on readest-app
## Why the Node `ws` package fails
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
```
The options.createConnection option is not implemented
```
This applies even with `compatibility_flags = ["nodejs_compat"]`.
## Correct pattern: fetch-based upgrade
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
```ts
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
const response = (await fetch(upgradeUrl, {
headers: { ...baseHeaders, Upgrade: 'websocket' },
})) as Response & { webSocket?: WebSocket & { accept(): void } };
if (response.status !== 101 || !response.webSocket) {
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
}
const ws = response.webSocket;
ws.addEventListener('message', onMessage);
ws.accept();
ws.send(payload);
```
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'``WebSocketPair` is a Workers-only global.
## Binary frames arrive as Blob (critical)
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
Example skeleton:
```ts
let pending: Promise<void> = Promise.resolve();
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
pending = pending.then(async () => {
const buf = await getBuf();
appendBinary(buf);
});
};
ws.addEventListener('message', (event) => {
const data = event.data;
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
else if (data instanceof ArrayBuffer) enqueue(() => data);
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
data.byteOffset, data.byteOffset + data.byteLength,
));
// ... handle text path: turn.end
// -> await pending, then resolve
});
```
## Where this is used
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
@@ -1,74 +0,0 @@
# CSS & Style Fixes Reference
## The `style.ts` Pipeline (`src/utils/style.ts`)
This is the most bug-prone file in the codebase (14+ fixes). It handles all EPUB CSS transformations.
### Key Functions
#### `getLayoutStyles()`
- Always-active styles applied to every EPUB section
- Controls: line-height, hyphens, image sizing, table display
- Rules here should NOT be conditional on user settings
- Common mistake: putting color-related rules here instead of `getColorStyles()`
#### `getColorStyles()`
- Conditionally applied when user enables "Override Book Color"
- Controls: foreground/background colors, mix-blend modes, image backgrounds
- Gate rules on `overrideColor` flag
#### `transformStylesheet()`
- Regex-based rewriting of EPUB CSS at load time
- Runs on every stylesheet loaded from the EPUB
- Used to neutralize problematic EPUB CSS declarations
#### `applyTableStyle()`
- Post-render function that scales tables to fit available width
- Uses `getComputedStyle()` (not inline `style.width`) to read actual width
- Has two scaling paths: column-width-based and parent-container-based
### Fix History by Issue
| Issue | Problem | Fix in style.ts |
|-------|---------|-----------------|
| #3494 | Line spacing not on `<li>` | Added `li` CSS rule for `line-height` and `hyphens` |
| #3448 | Calibre colors persist | Moved `.calibre` unset to `getColorStyles()`, added `background-color: unset` |
| #3441 | Body padding/margin | Added `padding: unset; margin: unset` to body in `getLayoutStyles()` |
| #3316 | Image bg unconditional | Made `background-color` rule conditional on `overrideColor` |
| #3377 | Image bg override | Same pattern as #3316, only override when `overrideColor` is true |
| #3334 | Generic font-family | `transformStylesheet()` replaces `font-family: serif/sans-serif` with `unset` on body |
| #3370 | user-select: none | `transformStylesheet()` replaces all `user-select: none` with `unset` |
| #3284 | Table scaling | Added fallback when `totalTableWidth` is 0 but parent has width |
| #3351 | Table display broken | Added `display: table !important` to table rule |
| #3274 | Image dimensions | Changed selectors to `:where(:not([width]))` and `:where(:not([height]))` |
| #3205 | Table width reading | Changed from `style.width` to `getComputedStyle().width`, fixed CSS var unit |
| #3112 | Mix-blend on all images | Narrowed selector to `.has-text-siblings` class |
| #3086 | Mix-blend on hr | Narrowed selector to `hr.background-img` |
| #3012 | Vertical alignment | Fixed available dimensions (subtract insets), replaced `100vw/vh` with CSS vars |
### Common Patterns
1. **Adding new element rules:** Copy the pattern from `p` rules (e.g., adding `li` for #3494)
2. **EPUB CSS neutralization:** Add regex in `transformStylesheet()` to replace problematic declarations
3. **Conditional overrides:** Use `overrideColor`/`overrideLayout` flags to gate rules
4. **Selector narrowing:** Use class qualifiers or attribute pseudo-selectors to avoid over-matching
5. **Table fixes:** Always use `getComputedStyle()`, not inline style. Check both width paths.
### CSS Variables from foliate-js
- `--available-width` - Usable content width (set by paginator.js)
- `--available-height` - Usable content height
- `--full-width` - Full viewport width (numeric, multiply by 1px)
- `--full-height` - Full viewport height (numeric, multiply by 1px)
- `--overlayer-highlight-opacity` - Highlight transparency (default 0.3)
### foliate-js Rendering (`packages/foliate-js/paginator.js`)
Key functions:
- `columnize()` - Paginated layout path
- `scrolled()` - Scrolled layout path
- `setImageSize()` - Constrains image dimensions to available space
- `#replaceBackground()` - Transfers EPUB backgrounds to paginator layer
- `snap()` - Swipe gesture detection for page turning
Common issue: A fix applied to `columnize()` but not `scrolled()` (or vice versa). Always check both paths.
@@ -1,40 +0,0 @@
---
name: D-pad Navigation Design
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
type: project
---
## D-pad Navigation Architecture
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
### Key Files
- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
- `src/helpers/shortcuts.ts``onToggleToolbar` (Enter key) toggles reader toolbar visibility.
- `src/app/reader/hooks/useBookShortcuts.ts``toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
- `src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
### Design Decisions
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
### Pitfalls
1. **WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
2. **`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
3. **Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
4. **Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
5. **Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
6. **Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
7. **`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
@@ -1,53 +0,0 @@
---
name: empty-start-cfi-sync
description: "Invalid synced-progress CFIs like epubcfi(/6/24!/4,,/20/1:58) — the empty-start range bug from the cfi-inert skip-link, and the read-side normalizeLocationCfi sanitizer"
metadata:
node_type: memory
type: project
originSessionId: ffa4a291-55fa-4cd5-8e35-0ac2852ff5c9
---
Synced progress CFIs of the form `epubcfi(/6/24!/4,,/20/1:58)` (a range with an
**empty start** component — the `,,`) are invalid: the start collapses to the
section beginning `(body, 0)` while the end reaches the section's last block, so
a receiving device navigates to the **wrong end** of the section.
**Root cause** — the cfi-inert a11y skip-link (`a11y.ts` prepends a 1×1
`position:absolute` `<div cfi-inert>` as body's first child). There was a
~2.5-month transitional window (foliate `c558766` 2026-03-11 → `569cc06`
2026-05-30) where `epubcfi.js getChildNodes` already skipped `cfi-inert` but
`paginator.js getVisibleRange` did **not** yet reject it. The relocate range's
START could anchor on the skip-link; `fromRange``nodeToParts` asks for its
index, `getChildNodes` filters it out, `findIndex` returns -1, the
`.filter(x => x.index !== -1)` drops the step, and the start collapses to the
body boundary → empty start. (Symmetric empty-END form from the next-section
skip-link on a section's last page.)
**Generation is fixed** by `569cc06` (live on `dev` via `c23c21d37`) — but that
does NOT repair CFIs already stored on the sync server. Those keep being served.
**Fix (this work):** `isMalformedLocationCfi(cfi)` predicate in `src/utils/cfi.ts`
— true for a degenerate range (empty `parts.start` or `parts.end` via
`CFI.parse`). Chose **discard over repair** (user call): don't derive a position
from a corrupt CFI; drop it and let a known-good fallback win.
- Applied ONLY at `useProgressSync.ts` `applyRemoteProgress`: a malformed
`syncedConfig.location` is set to `undefined` so it can't drive goTo, can't win
the `CFI.compare` gate, and is filtered out of the persisted config (local
location kept; stops re-propagation). A valid `xpointer` still recovers the
real position via `getCFIFromXPointer`.
- Applied at `useKOSync.ts` `generateKOProgress` (push side): if local
`progress.location` is malformed, skip the CFI→XPointer conversion and reuse
the last known-good `config.xpointer`. Critical because once a bad CFI is
pushed as an XPointer the "malformed" signal is lost — other devices pull a
plain XPointer pointing at the wrong section end and can't discard it. The
kosync RECEIVE path needs no guard: `getCFIFromXPointer` builds point CFIs from
point XPointers, which can't take the empty-start form.
- Deliberately NOT applied to `FoliateViewer.tsx` open path — that uses the
user's OWN local `config.location`; discarding it would dump them at book start
(`goToFraction(0)`). Left untouched per user preference; a legacy local bad
value self-heals on the next page-turn save.
Tests: predicate in `__tests__/utils/cfi.test.ts`; repro + flag in
`__tests__/utils/epubcfi-inert.test.ts`; discard behavior (no goTo, not
persisted) in `__tests__/hooks/useProgressSync.test.tsx`.
Related: [[kosync-cfi-spine-resolution]].
@@ -1,49 +0,0 @@
---
name: Design rules live in DESIGN.md
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
type: feedback
originSessionId: 85757e57-a029-40f8-b098-88039c43514b
---
The project's design system is documented at `apps/readest-app/DESIGN.md`.
**When the user articulates a UI/UX rule** ("X should always Y", "we follow Z
convention"), add it to DESIGN.md so it persists for the team and for future
sessions — don't just apply it inline and move on.
**Why:** Readest's UI is Adwaita-aligned, e-ink-first, cross-platform-aware.
A doc'd system avoids drift across panels and gives reviewers a reference
point. The user explicitly asked to "remember the UI/UX rules somewhere"
when refining the OPDS Integration sub-page.
**How to apply:** When the user surfaces a new rule:
1. Add it to the appropriate `DESIGN.md` section (numbered principles in §2,
anatomy details in §5, anti-patterns in §10).
2. Cross-reference from related sections so it's discoverable from multiple
entry points.
3. Save the actual code change reference if it captures a canonical example.
**Rules already codified there (don't re-invent — reference instead):**
- §2.12.7: surface continuity, color discipline, two-step depth, localized
hover, motion=color, eink-first, focus visibility.
- §2.8: RTL — always use logical properties (`ps`/`pe`/`ms`/`me`/`text-start`
/`text-end`/`border-s`/`border-e`/`start-*`/`end-*`). Never `pl`/`pr`/`ml`
/`mr`/`text-left`/`text-right`/`left-*`/`right-*`. The user is strict on
this — Readest ships RTL languages.
- §2.9: every panel/sub-page must open with title + one-line description.
- §3: surface tier hierarchy (window/view/card → base-200/100-tinted/100).
- §4: action vocabulary (Accent CTA, Suggested, Flat, Pill, Destructive,
ListExtension).
- §5: boxed list anatomy + uniform row height (`min-h-14 items-center`,
never `py-3`) + chromeless controls inside the box + end-aligned values
with custom `<MdArrowDropDown>` icon (don't trust daisyui's bg-image
chevron for trailing-edge alignment).
- §8: e-ink overlay rules.
- §10: anti-pattern catalog with real before/after examples.
**Common file paths to remember:**
- `apps/readest-app/DESIGN.md` — source of truth.
- `apps/readest-app/src/components/settings/SubPageHeader.tsx` — title +
description sub-page primitive that embodies §2.9.
- `apps/readest-app/src/components/settings/integrations/` — the canonical
reference implementation of the boxed-list-with-rows pattern.
- `apps/readest-app/src/styles/globals.css` — eink overlay rules at
`[data-eink='true']`.
@@ -1,11 +0,0 @@
---
name: Don't push on every change
description: Commit when work is done; don't auto-push every iteration during active debugging
type: feedback
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
---
Don't `git push` after each commit while a bug is being actively iterated on. Commit locally as needed but hold the push.
**Why:** When a fix doesn't actually solve the user-reported bug, every push is wasted CI cycles + remote churn the user has to look past on the PR. The user is testing live and will tell us when something's actually verified.
**How to apply:** During debugging or fix iterations on a single user-reported bug, commit locally only. Push when (a) the user confirms the fix works, (b) the user explicitly asks to push, or (c) we hit a clean done-state on a multi-step task. New commits + lint/test green is not enough.
@@ -1,28 +0,0 @@
---
name: en/translation.json holds ONLY plural variants and proper-noun overrides
description: For non-plural strings, do NOT add to en/translation.json — the source-code key IS the en value via `defaultValue: key`. ONLY plural strings need explicit `_one`/`_other` entries in en, because i18next needs the forms to pick from.
type: feedback
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
---
**Rule:** `public/locales/en/translation.json` is hand-curated and contains essentially two kinds of entries:
1. **Plural variants** (`<base>_one`, `<base>_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day".
2. **Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key.
Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites.
**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched.
**Workflow when adding `_(...)` calls:**
- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value.
- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `<base>_one` and `<base>_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one``Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun.
**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `<base>_one` and `<base>_other` exist in `en/translation.json`. (See conversation history for an implementation.)
**Where this bit us:**
- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form.
- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong.
- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings.
@@ -1,11 +0,0 @@
---
name: gstack upgrade location
description: Always upgrade gstack from the project directory (.claude/skills/gstack), not from a global install
type: feedback
---
When upgrading gstack, always run the upgrade from the current project's `.claude/skills/gstack` directory (local-git install), not from a global install path.
**Why:** The project uses a local-git gstack install at `apps/readest-app/.claude/skills/gstack`. Previous mistakes upgraded a global copy while the project's local copy stayed outdated.
**How to apply:** When `/gstack-upgrade` is invoked, ensure the `cd` and `git reset --hard origin/main && ./setup` happen inside the project's `.claude/skills/gstack` directory.
@@ -1,11 +0,0 @@
---
name: No lookbehind regex
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
type: feedback
---
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
@@ -1,24 +0,0 @@
---
name: No test seams in production code
description: Never import or call `__reset*ForTests` (or any test-only helper) from production modules — keep test orchestration on the test side
type: feedback
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
---
Production code must never import or call functions named `__reset*ForTests`
(or any other test-only seam). If a `__resetXForTests` function in module A
needs to also clear state owned by module B, the test file is responsible
for calling both resets — not module A's reset chaining into module B's.
**Why:** Importing a test-only helper into a production module pulls the
test seam into the prod import graph, blurs the test/prod boundary, and
risks the helper being shipped or mistakenly called at runtime. Caught
once in `src/services/sync/replicaSync.ts` where
`__resetReplicaSyncForTests` had been changed to call
`__resetSettledEventsForTests` from `@/utils/event` for "convenience."
**How to apply:**
- A `__resetXForTests` function should clear ONLY its own module's state.
- If a test needs a coordinated reset across modules, do it in the test
file's `beforeEach` / `afterEach` — call each module's seam directly.
- Never `import { __reset...ForTests }` inside `src/` outside of
`src/__tests__/`. A grep `grep -rn "__reset.*ForTests" src/ --include="*.ts" --include="*.tsx" | grep -v __tests__ | grep -v "^.*export const __reset"` should return zero hits.
@@ -1,11 +0,0 @@
---
name: Always use a new branch for new PRs
description: Each new PR/issue should get its own fresh branch from main, never reuse an existing feature branch
type: feedback
---
Always create a new branch from main for each new PR or issue. Never reuse an existing feature branch for unrelated work.
**Why:** The user corrected this when a storage fix was committed on the `feat/full-sync-annotations` branch instead of a dedicated branch. Mixing unrelated changes on the same branch makes PRs harder to review and manage.
**How to apply:** Before committing fixes, create a new branch like `fix/<topic>` from `origin/main`. Only reuse a branch if the work is directly related to that branch's existing purpose.
@@ -1,11 +0,0 @@
---
name: Always rebase before PR
description: Rebase to origin/main before creating pull requests
type: feedback
---
Always rebase the branch onto origin/main before creating a pull request.
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
@@ -1,11 +0,0 @@
---
name: test-file-filter
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
type: feedback
---
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
@@ -1,18 +0,0 @@
---
name: Use worktree for PR/issue/feature work
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
type: feedback
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
---
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
@@ -1,19 +0,0 @@
---
name: foliate-touch-listener-capture-phase
description: "To intercept/suppress reader touch gestures from the app, use capture-phase listeners — foliate-js's paginator registers bubble-phase doc listeners first"
metadata:
node_type: memory
type: reference
originSessionId: 4b0bfcd2-a4ed-4b3c-99c2-b3c37ef7c530
---
There are **three** independent touch-listener registrants on the foliate iframe `doc`:
1. `FoliateViewer.tsx` (~line 326) — passive forwarders that only `postMessage`.
2. `Annotator.tsx` (~line 332) — non-passive, drive text selection.
3. **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`) — non-passive, **bubble-phase**, registered during `view.open()` (so *before* any app-level `load` handler). It can `preventDefault`, set `#touchScrolled`, `scrollBy`.
Consequence: a bubble-phase app listener registered "before the existing FoliateViewer listeners" **cannot** `stopImmediatePropagation` the paginator — the paginator already ran. Registration order only controls listeners within the same phase, and the paginator's are earlier regardless.
**Fix pattern:** register with `{ capture: true, passive: false }`. Capture-phase listeners on `doc` fire before all bubble-phase listeners when the event target is a descendant, so capture-phase `stopImmediatePropagation()` suppresses paginator + Annotator + FoliateViewer handlers alike. Scrolled mode also needs `preventDefault` from the first armed move (the paginator early-returns on `scrolled`, so native container scroll is what moves content).
Verified end-to-end for the [[brightness-swipe-gesture]] feature (test asserts a bubble-phase paginator stand-in never fires after a capture-phase `stopImmediatePropagation`). Both Codex and a Claude subagent independently confirmed against `paginator.js` during the /autoplan review.
@@ -1,36 +0,0 @@
---
name: issue-4112-scroll-anchoring
description: "Root cause for readest#4112 scrolled-mode backward-nav bugs — scroll-anchoring suppressed at scrollTop 0 when prepending a section"
metadata:
node_type: memory
type: project
originSessionId: e0b11058-53ee-4554-a518-134f788823ee
---
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/32/3) because it races `#fillVisibleArea`'s reanchor. `primaryIndex` stays = target but the *visible* top section = target-1.
**Bug 2 (can't scroll up / jumps to beginning of prev section):** same suppression — prev section inserted above at scrollTop≈0 shifts viewport to the *beginning* of prev instead of staying put. Backward-preload is debounced-only (forward preload is eager/immediate) → asymmetry adds lag.
**FIX (landed on foliate-js branch `fix/scrolled-prev-prepend-anchor`, 2 changes in `#loadAdjacentSection` + `#goTo`):**
1. Manual scroll compensation at the single prepend choke point `#loadAdjacentSection`: when prepending in scrolled mode (`index < sortedViews[0]`), after `await view.load()`, set `#renderedStart` to `startBefore + addedSize`. `containerPosition += (#vertical ? -1 : 1) * correction`; no-op (correction≈0) when the browser already anchored at scrollTop>0. Fixes drift (Bug 1) + scroll-up-shows-beginning (Bug 2b).
2. The already-loaded `#goTo` branch only preloaded prev for *short* sections (`contentPages < columnCount`); changed to `needsPrev || this.scrolled` (+ `#isSameDirection` guard), mirroring `#display`. Fixes can't-scroll-up (Bug 2a) — the debounced backward-preload bails while `#stabilizing` after nav, so nav must preload prev itself.
**UX follow-ups (same branch, same file):**
3. **No blank flash on adjacent nav**: the already-loaded `#goTo` branch faded the container `opacity 0→1`; in continuous scrolled mode that flashed (worse after change 2 put the prev-load inside the blank window). Now `blank = !this.scrolled || this.noContinuousScroll` — continuous scrolled scrolls straight to the (already-rendered) target. `loadPrev` helper: paginated loads prev BEFORE the scroll (fill leading columns), scrolled loads it AFTER (instant transition; compensation keeps position).
4. **Eager backward preload**: removed the debounced, one-viewport-gated backward preload; added an eager one in the immediate scroll listener mirroring forward (`pagesBehind < minPages`, scrolled-gated). Fixes "scroll up dead-ends at top until you nudge down". Safe now because change 1 compensation handles position stability (the old "debounced to avoid cascade" reason is obsolete).
**Verified live + tests.** Scrolled regression tests live in `paginator-scrolled.browser.test.ts` (split out of the old `paginator-multiview.browser.test.ts`, which was renamed to `paginator-paginated.browser.test.ts` for the default/paginated + CFI tests). The 4 #4112 tests: drift / prev-preload-after-nav / no-blank / eager-backward-within-a-few-viewports (+ the moved 'columnCount=1 in scrolled mode' and the #3987 toggle-off test). `pnpm test` (4921) + `pnpm lint` + `pnpm format:check` + 57 paginator browser tests green (4 files: scrolled, paginated, expand, stabilization).
**GOTCHA for live verification:** programmatic `el.scrollTop = N` does NOT fire 'scroll' events in the claude-in-chrome context (real wheel/touch does). To test scroll-driven preloading via the JS console, set scrollTop AND `el.dispatchEvent(new Event('scroll'))`. Also: the Next.js 16 dev server bundles foliate-js (transpilePackages); editing paginator.js hot-reloads, but verify the served chunk has your edit (fetch `_next/static/chunks/packages_foliate-js_paginator_*.js` and grep) — recompile can lag.
readest-app test change is uncommitted on `dev`; foliate-js fix on branch `fix/scrolled-prev-prepend-anchor` (uncommitted) → needs a PR to readest/foliate-js then a submodule bump.
**Verification harness:** localhost:3000/reader/<id> book "凡人修仙传" (2470 sections). Expose `__pg`/`__fv`, tag view elements with section index via `iframe.contentDocument` identity, measure `visibleTopSec()` vs target after settle. jsdom CANNOT reproduce (no real layout); use Chrome.
Key file: `packages/foliate-js/paginator.js` (submodule, fork readest/foliate-js).
@@ -1,44 +0,0 @@
---
name: readest.koplugin i18n system
description: Custom gettext loader, .po catalog layout, and extract/apply scripts for the KOReader plugin at apps/readest.koplugin/
type: reference
originSessionId: 08cfd0cd-b710-4674-9c90-d2ae4827d071
---
The KOReader plugin (`apps/readest.koplugin/`) has its own gettext-based i18n system, parallel to but separate from the readest-app i18next setup.
## Loader
- File: `apps/readest.koplugin/i18n.lua` — isolated module, returns a callable table via `setmetatable({}, {__call = ...})`, so `_("msg")` syntax works as a drop-in replacement for `require("gettext")`. Provides `ngettext`/`pgettext`/`npgettext` too. Falls back to KOReader's native `gettext` for missing strings.
- All Lua sources do `local _ = require("i18n")` (not `require("gettext")`).
- **Never rename `i18n.lua` to `gettext.lua`** — it would shadow KOReader's module via `require("gettext")` and break the fallback chain (recursive require / never loaded).
## Catalog layout
- `apps/readest.koplugin/locales/<lang>/translation.po` — mirrors `apps/readest-app/public/locales/<lang>/translation.json` exactly, using **i18next-style codes** (`zh-CN`, not `zh_CN`).
- The loader converts KOReader's locale (e.g. `zh_CN.utf8``zh-CN`) before lookup.
- Fallback chain: full code → base lang (`pt_BR``pt`) → `zh-CN` for any unspecified zh variant.
- Language list is the single source of truth at `apps/readest-app/i18next-scanner.config.cjs` (`options.lngs`) — currently 31 languages.
## Scripts (in `apps/readest.koplugin/scripts/`)
- **`extract-i18n.js`** — primary tool. Run with `node scripts/extract-i18n.js`. Scans `*.lua` for `_("...")`, `_('...')`, and `_([[...]])` (with proper Lua-escape handling), reads each `.po`, **preserves existing translations**, adds new msgids with empty `msgstr`, drops obsolete msgids. Idempotent.
- **`apply-translations.js`** — bulk applier. Reads `/tmp/koplugin-translations/<lang>.json` files (key = msgid, value = translation) and fills empty `msgstr ""` lines only — **never overwrites** existing translations.
## Workflow for adding/changing strings
1. Edit Lua source(s). Use `_("Foo")` or `T(_("Foo %1"), arg)` (`T` from `require("ffi/util").template`) — **never** `_("Foo ") .. arg`, because RTL/verb-final languages can't reorder the placeholder.
2. `node scripts/extract-i18n.js` — adds new empty msgids, drops obsolete.
3. To translate: drop `<lang>.json` files into `/tmp/koplugin-translations/`, then `node scripts/apply-translations.js`.
4. Verify with `luac -p apps/readest.koplugin/*.lua` and re-run `extract-i18n.js` (should report no changes — idempotency check).
## Translation conventions
- Brand names "Readest" and "KOReader" stay untranslated.
- Technical terms ("PDF", "API", "URL", "Supabase", "Hash") generally kept as-is, sometimes transliterated in non-Latin scripts.
- Dialog title = title case (`Sync Info`); menu item label = sentence case (`Sync info`).
- Lower-confidence translations (bo, si, ta, bn, sl, fa) deserve native-speaker review.
## Storage conventions for plugin state
- Global plugin state: `G_reader_settings:saveSetting("readest_sync", settings)` — login tokens, auto-sync flag, etc.
- Per-book state: `ui.doc_settings:readSetting("readest_sync")` table — keys like `meta_hash_v1`, `last_synced_at_config`, `last_synced_at_notes` (seconds since epoch).
@@ -1,20 +0,0 @@
---
name: kosync-cfi-spine-resolution
description: "KOSync CFI↔XPointer conversion must resolve via the CFI's own spine index, not the paginator's primaryIndex (which lags during scroll)"
metadata:
node_type: memory
type: project
originSessionId: 41b15d60-969d-4173-9db7-d1515721e527
---
KOSync progress push/pull converts between foliate CFI and KOReader XPointer via `src/utils/xcfi.ts`. `XCFI` is constructed for ONE spine section (`spineItemIndex`) and `cfiToXPointer`/`xPointerToCFI` throw `CFI spine index N does not match converter spine index M` if asked to convert a CFI from a different section.
**Pitfall:** `view.renderer.primaryIndex` lags behind the viewport during scrolling (see `paginator.js` `#primaryIndex` comment). So `progress.location` can be a CFI in a different spine section than the currently-rendered primary view. Building the converter from the primary view's `doc`/`index` then converting `progress.location` throws on mismatch.
**Fix (PR branch fix/kosync-spine-index):** always go through the exported helpers `getXPointerFromCFI(cfi, doc?, index?, bookDoc)` / `getCFIFromXPointer(...)` in `xcfi.ts`. They call `XCFI.extractSpineIndex(cfi)` and, when the passed `index` ≠ the CFI's spine, load the correct section's document via `bookDoc.sections[xSpineIndex].createDocument()`. `generateKOProgress` in `src/app/reader/hooks/useKOSync.ts` had hand-rolled `new XCFI(primaryDoc, primaryIndex)` instead of using the helper — that was the bug.
**Why:** the helper already handles cross-section resolution correctly; never reconstruct the converter inline against `primaryIndex`.
**How to apply:** for any CFI→XPointer or XPointer→CFI in KOSync code, use the `getXPointerFromCFI`/`getCFIFromXPointer` helpers and pass `bookDoc` so off-screen sections can be loaded. Related: [[issue-4112-scroll-anchoring]] (same primaryIndex/scroll-lag family). The companion foliate `fromRange` crash (`Cannot destructure property 'nodeType'`) during relocate is separate scroll-lag noise, not the sync blocker.
**Conflict-detection comparison (`promptedSync` in `useKOSync.ts`):** KOReader's reported `remote.percentage` comes from CREngine pagination and is NOT comparable to Readest's progress. For reflowable books, convert the remote XPointer → local CFI → `view.getCFIProgress(cfi).fraction` (helper `getRemoteLocalFraction` in `kosyncProgress.ts`) and compare THAT to the local percentage; fall back to `remote.percentage` only when it can't be resolved locally (non-XPointer/Kavita progress or missing section). The resolved fraction also drives the "Approximately X%" remote preview so the dialog shows what was actually compared. Fixed-layout still compares page-derived `remotePercentage`. Threshold: `0.0001` cross-device, but loosened to `0.01` when `remote.device_id === settings.kosync.deviceId` (same device = our own earlier push; don't prompt on sub-page drift). Percentages render via `formatProgressPercentage` (2 decimals, `kosyncPreview.ts`).
@@ -1,70 +0,0 @@
# Layout & UI Fixes Reference
## Safe Area Insets
### Architecture
- Native plugins push inset values: iOS (`NativeBridgePlugin.swift`), Android (`NativeBridgePlugin.kt`)
- `useSafeAreaInsets` hook (`src/hooks/useSafeAreaInsets.ts`) reads and caches values
- Components use `gridInsets` for positioning relative to safe areas
- CSS: `env(safe-area-inset-top/bottom/left/right)` for CSS-level insets
### Common Issues
- **Stale insets after system UI change** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
- **iPad sidebar insets wrong** (#3395): Different inset handling needed for sidebar vs main view
- **Android nav bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for Android bottom padding
- **Zoom controls behind status bar** (#3426): Pass `gridInsets` through component chain, use `Math.max(gridInsets.top, statusBarHeight)`
### Rules (see also `docs/safe-area-insets.md`)
- Always pass `gridInsets` to overlay/floating components near screen edges
- On Android, account for system navigation bar with `env(safe-area-inset-bottom)`
- After toggling system UI visibility, force-refresh insets via `onUpdateInsets()`
## Z-Index Hierarchy
- Navigation buttons: `z-10` (when visible)
- Annotation nav bar: `z-10` (reduced from z-30 in #3386)
- TTS control button: ensure above page nav buttons
- Floating overlays: check against `gridInsets` positioning
## macOS Traffic Lights
- Managed by `trafficLightStore.ts` and `HeaderBar.tsx`
- **Fullscreen check required** (#3129): `await currentWindow.isFullscreen()` before hiding
- **Timeout for visibility toggle** (#3488): Use 100ms delay to prevent flickering
- **Sidebar interaction** (#3488): Check `getIsSideBarVisible()` before hiding traffic lights
- Never hide traffic lights when sidebar is open
## Touch/Input Issues
- **Slider hit area on iOS** (#3382): Use min-h-12, strip browser appearance with CSS
- **Context menu on macOS** (#3324): 100ms delay before `onContextMenu()` to let pointer events complete
- **Swipe sensitivity** (#3310): Use average velocity (distance/time) instead of instantaneous velocity for non-animated paging
- **Touchpad natural scrolling** (#3127): Respect system natural scrolling setting in `usePagination.ts`
## Dialog/Menu Layout
- **Dialog header** (#3352): Use `px-2 sm:pe-3 sm:ps-2` padding to align with border radius
- **Settings alignment** (#3151): Use `Menu` component instead of raw `div` for consistent styling
- **Dropdown for screen readers** (#3035): Use `DropdownContext` with overlay dismiss, not blur-based closing
## Component-Specific Fixes
### HeaderBar.tsx
- Traffic light visibility management
- Sidebar toggle persistent position (#3193)
- Library button placement
### PageNavigationButtons.tsx
- z-10 when visible (#3201)
- Always shown for screen readers (#3036)
- Toggle via `showPageNavButtons` setting
### ProgressInfo.tsx
- Use physical `page`/`pages` from renderer, not estimated values (#3213, #3200)
- CSS classes: `time-left-label`, `pages-left-label`, `progress-info-label` (#3343)
### ReadingRuler.tsx
- Remove `containerStyle` from overlay so dimmed area covers full screen (#3304)
### NavigationBar.tsx
- Handle `gridInsets` internally, not via pre-computed `navPadding` (#3466)
### ContentNavBar.tsx (annotation search results)
- Floating buttons with drop shadow, not full-width bar (#3386)
- z-10 z-index
@@ -1,21 +0,0 @@
---
name: manage-cache-ios-layout
description: "iOS app container layout and what the Manage Cache feature can/can't clear"
metadata:
node_type: memory
type: reference
originSessionId: 3512356b-a453-42d3-99f6-1ca43d06dd1e
---
Manage Cache feature: `src/utils/cache.ts` (helpers `getCacheEntries`/`getCacheStats`/`clearCacheEntries` over `CacheSource[]`), `src/app/library/components/CacheManagerWindow.tsx` (singleton dialog, `setCacheManagerDialogVisible`, `getCacheSources()` composes the source list), menu item in `SettingsMenu.tsx` Advanced Settings, mounted in `library/page.tsx`.
Scope: **native mobile apps only** (gated on `appService?.isMobileApp`; hidden on desktop + web). Sources cleared: iOS → Cache + Temp + `Documents/Inbox`; Android → Cache + Temp.
On-device analysis (dev build `com.bilingify.readest`, pulled via `xcrun devicectl device copy from --domain-type appDataContainer`):
- The `'Cache'` base = Tauri `appCacheDir()` = **`Library/Caches/com.bilingify.readest`** only — NOT all of `Library/Caches`. On the test device this held ~272 MB: a 249 MB duplicate dictionary (`concise-enhanced.mdx`, canonical copy lives in `App Support/Readest/Dictionaries/<id>/`), ~23 MB duplicate import-staged epubs (canonical in `App Support/Readest/Books/<hash>/`), the `search/` results cache, plus tiny system scratch. All safe to clear — every large item is an orphaned import/download staging duplicate.
- iOS open-in leftovers: **`Documents/Inbox`** (resolved via `documentDir()` + join `Inbox`, scanned/cleared with base `'None'` + absolute paths). Already-imported books linger here. The feature now clears it on iOS. (`tmp/<bundle>-Inbox` also exists but was empty.)
- NOT reachable by the `'Cache'` base: `Library/Caches/WebKit` (~173 MB, WKWebView disk cache — needs native `WKWebsiteDataStore.removeData`) and `tmp/` blob scratch (~59 MB, maps to `'Temp'` base, not currently cleared). These are the bigger "free up space" wins if the feature is ever expanded.
- Never clear `Library/Application Support/com.bilingify.readest/Readest` — the real Books/Dictionaries/Fonts/DB (~1.7 GB).
Root-cause follow-up worth doing: the import pipeline leaves staging copies in the cache root and `Documents/Inbox` instead of deleting them after import.
@@ -1,52 +0,0 @@
---
name: paginator-swipe-bg-flash
description: Swipe page-turn background flash —
metadata:
node_type: memory
type: project
originSessionId: 374255ae-fbfd-4933-bc47-555e541fa115
---
Swipe page-turn flash (white↔black pages) in the multiview paginator. Only on
**swipe + animation** (not arrow keys, not animation-off). Repro: mobile
emulator single column, swipe between a transparent page (colour comes from the
host behind, e.g. a white cover) and a CSS-coloured page (e.g. `body{background:#000!important}`).
`page2→page1` (black→white, backward) is 100%. Tell-tale: **slow swipe = big
flash, quick swipe = small flash on the trailing side** (flash width == drag distance).
**Root cause.** `#background` (foliate-js `paginator.js`) was a static
screen-space layer (a `repeat(cc,1fr)` grid coloured by each column's midpoint).
Doc bodies are set transparent (`doc.body.style.background='none'`), so a page's
full-bleed colour is painted by `#background`, NOT the iframe — except pages that
force their colour with `!important`, which keep it in-iframe. During a swipe the
content moves but `#background` did not: (1) the **drag** scrolls via `scrollBy`
with no `#replaceBackground` call (the debounced scroll handler doesn't fire
mid-drag), so the incoming page rendered over the *outgoing* page's stale colour;
(2) the **snap** pre-set the background to the *destination* (`#replaceBackground(offset)`)
then slid only the content for 300ms, so the outgoing page lost its colour
instantly and flashed across the area it still covered. Arrow keys don't flash
because they start from rest (content already aligned with the pre-set destination).
**Fix** (both phases needed — drag-only fix leaves the snap flash):
- `computeBackgroundSegments(views, scrollPos, bgSize, inset, containerSize)`
exported pure helper. One full-bleed segment per rendered view at
`inset + viewOffset - scrollPos`; transparent (`bg===''`) views get no segment
(host/theme shows through); segments meeting a container edge stretch into the
full-bleed gutter. `#background` is now `position:relative; overflow:hidden`
with absolutely-positioned segment divs (not a grid).
- Drag: rebuild every scroll in the container `scroll` listener, gated on
`!this.scrolled && !this.#isAnimating`.
- Snap (`#scrollTo`): build at the **current** position, then a per-rAF
`syncBackground` loop reads the animated view's transform
(`new DOMMatrix(getComputedStyle(child).transform).m41`, m42 for vertical) and
calls `#replaceBackground(startPosition - tx)` so segments track the content
every frame. Per-frame rebuild (not a CSS translate) because the incoming
page's segment must *grow* as it slides in.
Key insight: `expand()` makes a view element exactly `contentPages*columnSize`
wide, so per-view segments == the old per-column grid at rest, but they can slide.
Test: `src/__tests__/document/paginator-background-segments.test.ts` (pure helper).
Visual repro = synthetic `Touch` events via Chrome MCP + an rAF timeline sampling
`#background` segment `{left,width,bg}` + the view transform through drag AND snap.
foliate-js is a submodule — commit there + bump the pointer. See [[issue-4112-scroll-anchoring]].
@@ -1,91 +0,0 @@
# Platform Compatibility Fixes Reference
## Android
### WebView API Issues
- **`navigator.getGamepads()`** returns null on older WebView (#3245): Always null-check before `.some()`
- **`CompressionStream`** unavailable on some Android WebView versions (#3255): Add fallback zip compression path
- **Annotation tools unresponsive** (#3225): Don't call `makeSelection()` immediately on pointer-up; let popup flow complete naturally
- **Safe inset updates** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
- **Navigation bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for bottom padding
### General Android Rules
- Test with older WebView versions (97+)
- Always check API availability before calling Web APIs
- Touch event handling differs from iOS - avoid premature re-selection
## iOS
### Common Issues
- **Slider touch dead zones** (#3382): Strip native appearance, use larger hit areas (min-h-12)
- **Safe area insets stale** (#3395): Native Swift plugin must push updated insets
- **Section content caching** (#3242, #3206): Don't cache section content in foliate-js when updating subitems; cached content retains stale styles after mode switch
- **CompressionStream** (#3255): Also broken on iOS 15.x; zip.js has its own native API disable
- **zip.js native API** (#3170): Disable native `CompressionStream`/`DecompressionStream` on iOS 15.x
### iPad reports a desktop UA → never branch native dispatch on `getOSPlatform()`
- `getOSPlatform()` (utils/misc) is **user-agent based**, and iPadOS sends a desktop "Macintosh" UA → it returns `'macos'` on iPad. Any native-OS dispatch keyed on it misroutes iPad to the macOS path.
- Symptom seen: system dictionary on iPad threw `"Command show_lookup_popover not found"` — the macOS-only Rust command (`src-tauri/src/macos/system_dictionary.rs`); iOS only registers the plugin command `plugin:native-bridge|show_lookup_popover`.
- **Rule:** for OS-specific native dispatch/capability, use `appService.isIOSApp / isMacOSApp / isAndroidApp` (derived from the Tauri OS plugin `type()``OS_TYPE` in `nativeAppService.ts`), NOT `getOSPlatform()`. The misc.ts comment says this explicitly.
- Sync, non-React modules: `getInitializedAppService()` (environment.ts) returns the cached singleton synchronously (null pre-init). Used by `systemDictionary.ts` for `isSystemDictionarySupported()` (sync) + `invokeSystemDictionary()` (async).
### iOS-Specific Code
- `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift`
- Slider CSS: `-webkit-appearance: none; appearance: none` in globals.css
- `useSafeAreaInsets.ts` hook
## macOS
### Traffic Lights (Window Controls)
- Check `isFullscreen()` before hiding (#3129)
- Use timeouts (100ms) for visibility transitions (#3488)
- Don't hide when sidebar is open (#3488)
### Input Issues
- **Context menu steals event loop** (#3324): 100ms setTimeout before calling menu.popup()
- **Touchpad natural scrolling** (#3127): Respect system setting in `usePagination.ts`
- **Two-finger swipe** (#3127): Support trackpad two-finger swipe for pagination
### macOS-Specific Code
- `src-tauri/src/macos/` - Platform-specific Rust code
- `src/store/trafficLightStore.ts`
- `src/hooks/useTrafficLight.ts`
## Linux
### WebKitGTK Issues
- **View Transitions API unsupported** (#3417): Feature-detect `document.startViewTransition` before calling
- Use `useAppRouter.ts` to avoid transitions on Linux
## E-ink Devices
### Legibility Issues
- **Low contrast colors** (#3258): Use `not-eink:` Tailwind variant prefix for colors/opacity
- **Links invisible** (#3258): Don't apply `text-primary` (blue) on e-ink; use default text color
- **Opacity too low** (#3258): Don't apply `opacity-60`/`opacity-75` on e-ink devices
- **Highlight visibility** (#3299): Use foreground color for highlights in dark mode e-ink
### E-ink CSS Pattern
```
// Instead of:
className="text-primary opacity-60"
// Use:
className="not-eink:text-primary not-eink:opacity-60"
```
## Docker/Self-Hosting
- **Missing submodules** (#3233): Run `git submodule update --init --recursive` before build
- Simplecc WASM module must be initialized
## OPDS
- **Non-ASCII credentials** (#3436): Use `TextEncoder` + manual Base64 instead of `btoa()`
- **Author parsing** (#3120): Handle varied metadata structures in OPDS 2.0 feeds
- **Responsive layout** (#3418): Ensure catalog and download button layout works on small screens
## Cross-Platform Testing Checklist
1. Android (old WebView + current)
2. iOS (15.x + current)
3. macOS (traffic lights, trackpad)
4. Linux (WebKitGTK)
5. E-ink devices (contrast, colors)
6. Web (CloudFlare Workers deployment)
@@ -1,79 +0,0 @@
---
name: reading-ruler-line-aware
description: "Line-aware/column-aware reading ruler — geometry pipeline, key files, and the Range.getClientRects block-box gotcha"
metadata:
node_type: memory
type: project
originSessionId: f55c9971-c85f-44af-82c3-822e3bbd1129
---
Reading ruler (`src/app/reader/components/ReadingRuler.tsx`, pure logic in
`src/app/reader/utils/readingRuler.ts`) snaps to real rendered text lines instead of
stepping by a fixed arithmetic height. MERGED to main via PR #4358 (squash); the
`feat/line-aware-reading-ruler` branch + worktree are gone. Spec/plan under
`apps/readest-app/docs/superpowers/`.
Geometry pipeline:
- Line geometry comes from `progress.range.getClientRects()` (the foliate relocate range
spans first-to-last visible text). Single column + vertical mode → `buildLineBoxes` +
`snapReadingRulerToLines` (full-width band). Multi-column horizontal → `buildReadingRulerColumns`
+ `snapReadingRulerColumns`, band spans one column, dims the rest incl. the other column.
- The snap functions return the real line-block extent `{start,end}`; the band is sized
dynamically to that block + symmetric padding (`calculateReadingRulerPadding` =
`round(fontSize*lineHeight*0.4)`) so padding is equal all around. `currentPosition` stays
the band center % (drag/persistence); `bandSize` state holds the dynamic thickness, falling
back to `baseRulerSize + 2*padding` for scrolled/fixed-layout/drag/unmeasured.
- Multi-column detection: `view.renderer.columnCount` (already on the `Renderer` type).
- Snapping works in scrolled mode too (`supportsLineSnap` no longer excludes scrolled).
Single-column geometry goes through `buildVisibleLineBoxes` → frame-offset mapping, which
is REQUIRED in scrolled mode (the visible section iframe is offset vertically by the scroll;
there are several stacked section iframes, frame tops far negative). Auto-move stays disabled
in scrolled mode (no jump on scroll); the band derives from the saved position on mount.
- GOTCHA: in scrolled mode `progress.range` (the relocate range) covers only PART of the
viewport, so using it would make the band hit a false end mid-view and scroll early (skipping
lines). Scrolled mode instead builds boxes from the visible section(s) directly
(`buildScrolledLineBoxes`: walk `view.renderer.getContents()`, `selectNodeContents(body)`,
frame-offset map, filter to viewport). At a view edge a tap sets `pendingScrollAlignRef`; the
view scrolls and the next relocate realigns the band to the first (forward) / last (backward)
group. `filterVisibleLineBoxes` (≥0.5 visible) keeps the mount/realign placement on-screen.
- GOTCHA: do NOT call `renderer.scrollBy()` yourself to advance the band in scrolled mode — a
manual scroll that crosses a section boundary fires the relocate MID-relayout, so
`getContents()` frame offsets are stale and `buildScrolledLineBoxes` returns garbage (huge
blocks spanning gaps) → the band loops in place. Instead, when the next block can't be
centered, return false so foliate page-scrolls (its relocate fires AFTER layout settles) and
realign via `pendingScrollAlignRef`. Band is always centered on its block (no center clamp).
- Band height is capped at `calculateReadingRulerSize(lines + 1, …)` so a tall element (e.g. a
full-page image) inside the snapped block can't expand the band to cover the whole image.
- **Coordinate mapping**: paginated multi-column pages shift the iframe far off-screen
horizontally (`frameRect.left` was -4773 in testing); map iframe-content rects to overlay
coords with the iframe `frameElement` offset (`mapRangeRectsToOverlay`), not just
`rect.top - containerRect.top`.
**GOTCHA (caused a paragraph-skip bug):** `Range.getClientRects()` aggregates the border
boxes of every *fully-enclosed element*, so multi-line `<p>`/container blocks return rects
far taller than a text line (e.g. h=410 spanning a whole paragraph) alongside the line
rects. An overlap-based line merge chains those into one giant "line", making the snap skip
an entire paragraph — worse with more paragraphs per column. Fix: `dropBlockRects()` discards
rects whose cross-axis thickness > 1.8× the median line thickness before clustering.
**Vertical key mapping**: in vertical writing mode only Up/Down arrows move the ruler;
Left/Right turn pages (`isReadingRulerMoveKey(side, isVertical)` in `readingRuler.ts`, gating
`moveReadingRuler` in `useBookShortcuts.ts`). Taps always move the ruler regardless of the
tapped side (the restriction is keyboard-only; the tap path in `usePagination.ts` is untouched).
**Scrolled vertical-rl gotcha**: scrolled vertical scrolls HORIZONTALLY (section iframes stack
along x; all share top/bottom). `progress.range` covers only the left ~30% of the visible width,
so building boxes from it makes the edge realign land mid-view. `buildScrolledLineBoxes` is now
vertical-aware (horizontal frame-visibility filter `frame.right<=cont.left || frame.left>=cont.right`,
`buildLineBoxes(mapped, isVertical, …)`) and the scrolled edge-scroll path (`buildScrolledLineBoxes`
choice, centerable check) no longer excludes vertical (`scrolled = !!viewSettings.scrolled`, was
`&& !isVertical`). At the last column, advancing scrolls the view forward and snaps the band to the
first (rightmost) column; backward snaps to the last (leftmost). PAGINATED vertical already paged at
the boundary (snap returns null → handler returns false → keyboard `viewPagination``view.next()`;
auto-move effect places the band on the new page's first column).
Verified live with Chrome MCP by reconstructing the column pipeline in-page and dispatching
ArrowRight/ArrowLeft. Note: rapid synthetic key events in a tight loop get coalesced/throttled
by the reader's nav handling — drive one press per tool call with real time between. To inspect
logged objects (read_console_messages shows "Object"), monkey-patch console.log in-page to push
JSON.stringify'd args into a window array, then read that array.
@@ -1,53 +0,0 @@
---
name: Share-a-Book Feature (in progress)
description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path.
type: project
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
---
Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing).
**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top.
**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing.
## Locked decisions (authoritative)
- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction.
- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are.
- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`.
- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged.
- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create.
- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically.
- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts.
- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write.
- **Per-user 50-share cap**, enforced at create-time.
- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails.
- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern.
- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host.
## Cherry-picks accepted (CEO review SELECTIVE EXPANSION)
1. **Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context).
3. **1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`.
4. **Branded OG image**`/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist.
Deferred to TODOS.md: QR code on landing, notify-on-download toggle.
## Open implementation questions (resolve mid-build)
1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD.
2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file.
3. `@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible.
4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build.
## Critical files for implementation
See "Critical Files (modify or create)" table in the plan. Key starting points:
- `src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts`
- `src/libs/share-server.ts` (new) — `generateShareToken()`, `hashShareToken(raw)`
- `src/libs/share.ts` (new) — typed client used by dialog, manage section, deeplink hook, landing page
- `src/utils/share.ts` (new) — `buildShareUrl(token)`, `parseShareDeepLink(url)`
- Dialog reference: `src/components/Dialog.tsx`, `BookDetailModal.tsx`
- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`)
- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts`
- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn)
@@ -1,17 +0,0 @@
---
name: toc-expand-and-autoscroll
description: TOC sidebar auto-expansion policy + the Virtuoso scrollToIndex-after-expansion race that breaks the initial scroll-to-current-chapter
metadata:
node_type: memory
type: project
originSessionId: 4d4eefcb-324a-4342-9472-9dae7cc57b41
---
Issue #4059: the TOC sidebar used to auto-expand *every* top-level container (`computeExpandedSet` added all `toc.filter(item => item.subitems?.length)`), so multi-volume books ("文学必读合集20册") opened fully expanded. Fix: expand only the current location's ancestor path (`findParentPath`), with a single-root fallback (expand `toc[0]` only when `toc.length === 1`) so a one-root-wrapper TOC isn't reduced to one row. Pure logic lives in `src/app/reader/components/sidebar/tocTree.ts` (extracted from TOCView so it's unit-testable — TOCView itself can't be imported in jsdom: react-virtuoso + OverlayScrollbars CSS).
Collapsing-by-default introduced a **regression**: the current chapter no longer scrolled into view on fresh load. Root cause is two-fold, both stemming from the pinned sidebar mounting *before* the first relocate (progress is null at mount, so `initialScrollTarget.index` is 0 and Virtuoso's `initialTopMostItemIndex` can't anchor):
1. When progress arrives, the current volume expands and the virtualized list grows by dozens of rows in one commit. Virtuoso emits a synthetic `onScroll`; the old handler treated *any* scroll while a scroll was queued as "user took over" and cleared `pendingScrollRef`. Fix: ignore a queued-state scroll unless a real gesture preceded it — track `userInputRef` via capture-phase `wheel`/`touchstart`/`pointerdown`/`keydown` listeners on the scroller; `if (pendingScrollRef.current && !userInputRef.current) return;`.
2. Even with pending preserved, a single `scrollToIndex(idx, {align:'center'})` fired in the same commit as the 28→90 row growth **lands short** — Virtuoso scrolls before measuring the new rows. Fix: re-assert the scroll on the next `requestAnimationFrame` (only for the instant `behavior:'auto'` case). The toggle-sidebar (remount) path always worked because remounting feeds `initialTopMostItemIndex` before first render.
Verified live: 红楼梦 book at `/reader/217e3f1e...` — only 下卷 expanded, current chapter centered (0px from viewport center), user scroll no longer snaps back. Related: [[issue-4112-scroll-anchoring]], [[reading-ruler-line-aware]], [[virtuoso_overlayscrollbars]].
@@ -1,50 +0,0 @@
# TTS (Text-to-Speech) Fixes Reference
## Architecture
### Key Components
- `TTSController` (`src/services/tts/TTSController.ts`) - Core state machine
- `EdgeTTSClient` (`src/services/tts/EdgeTTSClient.ts`) - Edge TTS provider
- `useTTSControl` hook (`src/app/reader/hooks/useTTSControl.ts`) - React integration
- `useTTSMediaSession` hook (`src/app/reader/hooks/useTTSMediaSession.ts`) - Media controls
### Section-Aware TTS Model
TTS tracks its own section independently from the view via `#ttsSectionIndex`:
- `#initTTSForSection()` - Creates TTS document for a section without changing the view
- `#initTTSForNextSection()` / `#initTTSForPrevSection()` - Navigate TTS across sections
- `#getHighlighter()` - Only returns highlighter if view section matches TTS section
- `onSectionChange` callback - Notifies UI when TTS crosses section boundary
- Highlights use CFI strings (not raw Range objects) for cross-section compatibility
### State Management Pitfalls
1. **`#ttsSectionIndex` must match view section for highlights to work**
- If `-1`, all highlight calls are suppressed
- `shutdown()` sets it to `-1` but must also null out `this.view.tts`
2. **Guards/Refs that block re-entry:**
- The old `ttsOnRef` guard blocked TTS restart from annotations (removed in #3292)
- `view.tts` reference surviving shutdown blocked re-initialization (#3400)
3. **Timeouts that fire after pause:**
- Edge TTS had a safety timeout that advanced sentences even when paused (#3244)
- Solution: removed the entire `ontimeupdate` safety timeout mechanism
## Fix History
| Issue | Problem | Root Cause | Fix |
|-------|---------|------------|-----|
| #3100 | TTS scrolls too far | TTS coupled to view section | Added `#ttsSectionIndex`, "Back to TTS Location" button |
| #3198 | TTS doesn't follow to next section | No `onSectionChange` callback | Added section change notification, extracted hooks |
| #3244 | Paused TTS advances | Safety timeout fires after pause | Removed `ontimeupdate` timeout mechanism |
| #3291 | TTS fails without lang attribute | Invalid SSML from missing lang | Set lang/xml:lang on html element from `ttsLang` |
| #3292 | Can't restart TTS from annotation | `ttsOnRef` blocks re-entry | Removed the guard ref entirely |
| #3400 | TTS highlight stops after restart | `view.tts` not nulled on shutdown | Added `this.view.tts = null` in `shutdown()` |
## Debugging TTS Issues
1. **TTS doesn't start:** Check `#initTTSForSection()` - does `view.tts.doc === doc` shortcut early?
2. **No highlights:** Check `#ttsSectionIndex` matches view's section index
3. **Advances when paused:** Look for setTimeout/timer callbacks that bypass pause state
4. **Can't restart:** Check for refs/guards that prevent re-entry into speak handlers
5. **Fails on some chapters:** Check if chapter has lang attribute and XHTML namespace
6. **SSML errors:** Check `src/utils/ssml.ts` for proper namespace/lang handling
@@ -1,92 +0,0 @@
---
name: Virtuoso + OverlayScrollbars pattern
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
type: reference
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
---
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
## Migration from `customScrollParent`
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
- Remove the outer `OverlayScrollbarsComponent` wrapper
- Use `scrollerRef` instead to capture Virtuoso's scroller element
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
## Boilerplate
```tsx
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import 'overlayscrollbars/overlayscrollbars.css';
// Inside the component:
const osRootRef = useRef<HTMLDivElement>(null);
const [scroller, setScroller] = useState<HTMLElement | null>(null);
const [initialize, osInstance] = useOverlayScrollbars({
defer: true,
options: { scrollbars: { autoHide: 'scroll' } },
events: {
initialized(instance) {
const { viewport } = instance.elements();
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
},
},
});
useEffect(() => {
const root = osRootRef.current;
if (scroller && root) {
initialize({ target: root, elements: { viewport: scroller } });
}
return () => osInstance()?.destroy();
}, [scroller, initialize, osInstance]);
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
const div = el instanceof HTMLElement ? el : null;
setScroller(div);
// If parent needs the scroller (e.g. for pull-to-refresh):
onScrollerRef?.(div as HTMLDivElement | null);
}, [onScrollerRef]);
```
## JSX structure
```tsx
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
<Virtuoso
scrollerRef={handleScrollerRef}
style={{ height: containerHeight }}
totalCount={items.length}
itemContent={renderItem}
overscan={200}
/>
</div>
```
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
## Footer spacer
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
```tsx
const VIRTUOSO_COMPONENTS = {
List: MyListComponent,
Footer: () => <div style={{ height: 34 }} />,
};
```
## Key points
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
## Used in
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
File diff suppressed because it is too large Load Diff
@@ -1,5 +0,0 @@
## Test-First Development
- Always write a failing unit test **before** implementing a fix.
- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes.
- Run the full test suite (`pnpm test`) after changes to ensure no regressions.
@@ -1,5 +0,0 @@
## TypeScript
- Never use the `any` type. Use `unknown`, proper types, or generics instead.
- Strict mode is enabled. Target is ES2022.
- Unused vars prefixed with `_` are allowed (ESLint configured).
@@ -1,9 +0,0 @@
## Verification (done-conditions)
Before marking work complete, all applicable checks must pass:
1. `pnpm test` — unit tests (vitest)
2. `pnpm lint` — Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
3. `pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
@@ -1,111 +0,0 @@
---
name: i18n-koplugin
description: >
Extract i18n strings from readest.koplugin Lua sources and translate empty
msgstrs in apps/readest.koplugin/locales. Use when the user invokes
/i18n-koplugin or asks to extract/translate koplugin i18n strings.
Runs scripts/extract-i18n.js to sync .po catalogs from `_("...")` calls,
then fills any empty `msgstr ""` entries across all locale files.
user_invocable: true
---
Extract/translate i18n strings for `readest.koplugin`. The catalogs are gettext `.po` files (not JSON like the main app). Run from the repo root or any worktree — the script resolves paths relative to the plugin dir.
## Step 1: Determine the working directory
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The plugin dir is `<repo-root>/apps/readest.koplugin`.
## Step 2: Extract msgids from Lua sources
```bash
cd <repo-root>/apps/readest.koplugin
node scripts/extract-i18n.js
```
This scans every `*.lua` file under `apps/readest.koplugin/` (except `spec/` and dotdirs) for `_("...")` and `_([[...]])` calls, then for each language listed in `apps/readest-app/i18next-scanner.config.cjs`:
- appends new msgids with empty `msgstr ""`
- preserves existing translations
- drops obsolete msgids
- rewrites the `.po` header (Plural-Forms etc.)
The output prints `<lang> <kept>/<total> (-<dropped> obsolete)` per locale.
## Step 3: Find untranslated entries
An untranslated entry is a non-empty `msgid` followed by an empty `msgstr ""` (the file's header pair `msgid ""` / `msgstr ""` is NOT a translation — skip it).
```bash
cd <repo-root>/apps/readest.koplugin/locales
# List locales that still have untranslated strings, with counts
for f in */translation.po; do
# Count empty msgstrs that follow a non-empty msgid
n=$(awk '
/^msgid "/ { msgid=$0; next }
/^msgstr ""$/ { if (msgid != "msgid \"\"") c++; next }
' "$f")
[ "$n" -gt 0 ] && echo "$f: $n untranslated"
done
```
If no results, report that all strings are translated and stop.
To list the actual untranslated msgids in one locale:
```bash
awk '
/^msgid "/ { msgid=$0; next }
/^msgstr ""$/ { if (msgid != "msgid \"\"") print msgid; next }
' <repo-root>/apps/readest.koplugin/locales/<lang>/translation.po
```
## Step 4: Translate empty msgstrs
For each empty `msgstr ""` found:
1. Read the preceding `msgid "..."` — that's the English source string.
2. Identify the target locale from the file path (e.g., `locales/ja/translation.po` → Japanese; see table below).
3. Provide an accurate translation. Use the locale reference table for the language; match the tone/terminology already used in the same file (check existing translated entries for context).
4. Preserve `printf`-style placeholders verbatim: `%s`, `%d`, `%1$s`, `%(name)s`, etc.
5. Preserve newlines as `\n`, tabs as `\t`, and escape `"` as `\"` and backslashes as `\\` inside the msgstr.
Edit the `.po` files directly with the Edit tool — do NOT use sed for this, because msgids may contain characters that confuse shell quoting. Each replacement should target the unique `msgid "<English>"\nmsgstr ""` block:
Old:
```
msgid "<English string>"
msgstr ""
```
New:
```
msgid "<English string>"
msgstr "<translation>"
```
Batch all locales for the same key together when possible — keeps the translation set consistent.
### Locale reference
The supported language set is **not hardcoded in this skill**. The ground truth is `apps/readest-app/i18n-langs.json` — both `i18next-scanner.config.cjs` (via `require`) and `src/i18n/i18n.ts` (via JSON import) source from it, and `extract-i18n.js` reads `lngs` from the scanner config at runtime. To list the current set:
```bash
cat <repo-root>/apps/readest-app/i18n-langs.json
```
Map each code to a language name when translating. If a code in `i18n-langs.json` is missing from the `LANG_META` table inside `extract-i18n.js`, the script prints `<code> skipped (no metadata in extract-i18n.js)` — in that case, add the metadata entry there first, then re-run extraction.
## Step 5: Verify
Re-run the count loop from Step 3 and confirm zero untranslated strings remain. Report:
- number of msgids extracted
- per-locale count of strings translated
- any locales that were already complete
Optionally, run the koplugin Lua tests if `busted`/`luajit` are installed:
```bash
cd <repo-root>/apps/readest-app
pnpm test:lua
```
@@ -1,96 +0,0 @@
---
name: i18n
description: >
Extract i18n strings, translate missing translations, or add a new language to readest-app.
Use when the user invokes /i18n or asks to extract/translate i18n strings or add a new locale.
Runs i18next-scanner to extract keys, then translates any __STRING_NOT_TRANSLATED__
placeholders across all locale files.
user_invocable: true
---
Extract/translate i18n strings or add a new language for readest-app. Run from the app directory (either the main repo or a worktree).
## Step 0: Determine the mode
- If the user asks to **add a new language/locale**, go to **Adding a New Language** below.
- Otherwise, go to **Extracting & Translating Strings** below.
## Step 1: Determine the working directory
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The app directory is `<repo-root>/apps/readest-app`.
---
## Adding a New Language
When the user asks to add a new language (e.g., "add Hungarian", "add hu locale"):
### Step A1: Register the locale in two places
1. **`i18n-langs.json`** — Append the locale code to the array. Both `i18next-scanner.config.cjs` and `src/i18n/i18n.ts` import from this file, so they pick up the new entry automatically.
2. **`src/services/constants.ts`** — Add an entry to `TRANSLATED_LANGS` with the locale code and native language name (e.g., `hu: 'Magyar'`). If the locale already exists in `TRANSLATOR_LANGS`, remove the duplicate there since it will be inherited via the spread.
### Step A2: Generate the translation file
```bash
cd <app-dir>
pnpm run i18n:extract
```
This creates `public/locales/<code>/translation.json` with all keys set to `__STRING_NOT_TRANSLATED__`.
### Step A3: Translate all strings
Follow **Step 4** below to translate every `__STRING_NOT_TRANSLATED__` entry in the new locale file.
### Step A4: Verify
Follow **Step 5** below to confirm zero remaining untranslated strings for the new locale.
---
## Extracting & Translating Strings
### Step 2: Extract i18n strings
```bash
cd <app-dir>
pnpm run i18n:extract
```
This runs `i18next-scanner` which scans source files for translation keys and adds any new keys to all locale files with `__STRING_NOT_TRANSLATED__` as the placeholder value.
### Step 3: Find untranslated strings
```bash
grep -r "__STRING_NOT_TRANSLATED__" <app-dir>/public/locales/
```
If no results, report that all strings are already translated and stop.
### Step 4: Translate missing strings
For each `__STRING_NOT_TRANSLATED__` found:
1. Identify the English key (e.g., `"Hide Scrollbar"`)
2. Identify the target locale from the file path (e.g., `locales/ja/translation.json` -> Japanese)
3. Provide an accurate translation for each locale
Use `sed -i ''` on macOS to replace in-place. Handle all locales in one batch:
```bash
cd <app-dir>/public/locales
sed -i '' 's/"<Key>": "__STRING_NOT_TRANSLATED__"/"<Key>": "<translation>"/' <locale>/translation.json
```
### Locale reference
The canonical, complete list of supported locales lives in `i18n-langs.json` (codes only) and `TRANSLATED_LANGS` in `src/services/constants.ts` (codes → native display names). Read those for the source of truth — translate every locale that appears in `i18n-langs.json`. Don't carry forward older "out-of-scope" exclusions like `pt-BR` or `uz`; if it's in `i18n-langs.json` it ships, and it needs translation.
### Step 5: Verify
```bash
grep -r "__STRING_NOT_TRANSLATED__" <app-dir>/public/locales/
```
Confirm zero remaining untranslated strings. Report the number of keys translated and locales updated.
+2 -8
View File
@@ -2,11 +2,5 @@ PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_DEV_BASE64="cGtfdGVzdF81MVJmQmdLRTdSWW5pTWsxc0tDV2RUd2hMZzcySzk4eDRWcjlIdDdsRFBONngzcnpZYmhydGtNQnpDdzZKbHFaRVVITVp5eVNjVXhCZXVkcGppWTk0WXNHcDAweFlRRnRRaUU="
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_BASE64="cGtfbGl2ZV81MVFYN3dRRU5ndjJFOUxQRHpZUlE5TlJJeTNjd09EZ1AzSkNFRHRPWlFtdFJWc3Brd053ZE1NNUpIVnVPTmJWcjZ3VGFCMUNZR1pJMmRPVWppTkY0bHJvVjAwalE4TkpkdWk="
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
-32
View File
@@ -1,32 +0,0 @@
NEXT_PUBLIC_POSTHOG_KEY=YOUR_POSTHOG_KEY
NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
NEXT_PUBLIC_API_BASE_URL=https://your-api-base-url.com
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
# r2, s3
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
R2_REGION=YOUR_R2_REGION
S3_ENDPOINT=PLACE_HOLDER
S3_ACCESS_KEY_ID=PLACE_HOLDER
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
S3_BUCKET_NAME=PLACE_HOLDER
S3_REGION=PLACE_HOLDER
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
+1 -3
View File
@@ -1,3 +1 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
DBUS_ID=com.bilingify.readest
NEXT_PUBLIC_APP_PLATFORM=tauri
-2
View File
@@ -1,2 +0,0 @@
NEXT_PUBLIC_APP_PLATFORM=tauri
AI_GATEWAY_API_KEY=your_key_here
-3
View File
@@ -1,3 +0,0 @@
NEXT_PUBLIC_APP_PLATFORM=web
AI_GATEWAY_API_KEY=your_key_here
NEXT_PUBLIC_AI_GATEWAY_API_KEY=your_key_here
+1 -32
View File
@@ -8,11 +8,6 @@
# testing
/coverage
.test-sandbox-node/
.vitest-attachments/
# benchmarks — personal local perf history; share via PR/issue copy-paste
bench/results.jsonl
# next.js
/.next/
@@ -37,7 +32,7 @@ yarn-error.log*
/private_keys
# plists
Entitlements*.plist
*.plist
# local confs
tauri.*.conf.json
@@ -45,38 +40,12 @@ tauri.*.conf.json
# vercel
.vercel
# open-next
.open-next
.wrangler
# typescript
*.tsbuildinfo
next-env.d.ts
# eslint
.eslintcache
#generated
src-tauri/gen
# vendor
/public/vendor
# Auto Generated PWA files
/public/sw.js
/public/workbox-*.js
/public/fallback-*.js
/public/swe-worker-*.js
/dist/
.context/
.claude/settings.local.json
.claude/skills
# Playwright web e2e
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
-112
View File
@@ -1,112 +0,0 @@
## Project Overview
Readest is a cross-platform ebook reader built as a **Next.js 16 + Tauri v2** hybrid app. It's part of a pnpm monorepo at `/apps/readest-app/`. The app runs on web (CloudFlare Workers), desktop (macOS/Windows/Linux via Tauri), and mobile (iOS/Android via Tauri).
## Common Commands
```bash
# Development
pnpm dev-web # Web-only dev server (no Rust compilation needed)
pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
# Building
pnpm build # Build Next.js for Tauri
pnpm build-web # Build Next.js for web deployment
# Testing (see [docs/testing.md](docs/testing.md) for full details)
pnpm test # Unit tests (vitest + jsdom)
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
pnpm test -- --watch # Watch mode
pnpm test:browser # Browser tests (Chromium via Playwright)
pnpm tauri:dev:test # Start Tauri app with webdriver
pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Biome formatter (runs from monorepo root)
pnpm format:check # Check formatting without writing (Biome)
# Rust
pnpm fmt:check # Check formatting Rust code (src-tauri)
pnpm clippy:check # Lint Rust code (src-tauri)
```
### Source Layout
| Directory | Purpose |
| ----------------- | ------------------------------------------------------------- |
| `src/app/` | Next.js App Router pages and API routes |
| `src/components/` | React components (reader, settings, library, assistant, etc.) |
| `src/services/` | Business logic: TTS, translators, OPDS, sync, AI, metadata |
| `src/store/` | Zustand state stores |
| `src/hooks/` | Custom React hooks |
| `src/libs/` | Document loaders, payment, storage, sync |
| `src/utils/` | Pure utility functions |
| `src/types/` | TypeScript type definitions |
| `src/context/` | React Context providers (Auth, Env, Sync, etc.) |
| `src/workers/` | Web Workers for background tasks |
| `src-tauri/` | Rust backend: Tauri plugins, platform-specific code |
### Path Aliases (tsconfig)
- `@/*``./src/*`
- `@/components/ui/*``./src/components/primitives/*`
### Rust Backend (`src-tauri/`)
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
## Git Worktrees
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
```bash
pnpm worktree:new feat/my-feature # New branch from origin/main
pnpm worktree:new 3837 # Checkout PR #3837 with push access to fork
```
## Agent Workspace
Project-related agent context lives under `.agents/`, which is a symlink to `.claude/`. Treat `.agents/` as the canonical path when looking for or updating local agent material:
- `.agents/memory/` — persistent project memory and recurring context
- `.agents/plans/` — active or archived implementation plans
- `.agents/rules/` — project rules for test-first work, TypeScript, verification, and related workflows
## Project Rules
Rules are in `.agents/rules/`: test-first, typescript, verification.
### Implementation Scope
For every coding task, write the minimum code that solves the requested problem.
- Do not add features beyond what was asked.
- Do not add abstractions for single-use code.
- Do not add flexibility or configurability unless requested.
- Do not add error handling for impossible scenarios.
- If a solution is much longer than necessary, simplify it before finishing.
- Before shipping, ask: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
### i18n
See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `stubTranslation` usage in non-React modules, and extraction workflow.
### Safe Area Insets
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
### Design System
UI/UX rules — surface tiers, action vocabulary, settings primitives (`BoxedList`, `SettingsRow`, `SettingsSwitchRow`, `SettingsSelect`, `NavigationRow`, `Tips`, etc.), boxed-list anatomy, RTL conventions, e-ink overlay, and anti-patterns — live in [DESIGN.md](DESIGN.md). Codify recurring decisions there so they persist for the team and future contributors. Reach for the primitives in `src/components/settings/primitives/` instead of inlining chassis classes.
### E-ink mode
Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones:
- **Surfaces / inputs** — add `eink-bordered`. In eink mode it swaps to `bg-base-100` + 1px `base-content` border. Use it on inputs, custom button backgrounds, ghost-styled cancel buttons, and any container that needs a visible boundary.
- **Primary action buttons** — add `btn-primary` (alongside whatever Tailwind classes you use for color themes). The `[data-eink] .btn-primary` rule inverts to `base-content` bg + `base-100` text so the primary CTA stays distinct from secondary actions.
- **`.modal-box`** picks up no-shadow + 1px border automatically; dialogs that use it don't need additions.
- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference.
When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
-972
View File
@@ -1,972 +0,0 @@
## Readest Design Language
Readest's UI is **Adwaita-aligned**, **e-ink-first**, **cross-platform-aware**. This doc is the
reference for that language: principles, vocabulary, anti-patterns. New work should read it
before reaching for daisyui defaults; existing work is gradually migrating toward it.
### Status
This doc is the **first articulation** of the system, not a retrospective. Many existing
components don't fully match it yet (especially older buttons and ad-hoc panels). The goal
is that **new code uses these conventions** and **migrations land opportunistically** as
features get touched.
---
### 1. Identity & lineage
Readest's visual language descends from **Adwaita / libadwaita** — GNOME's design system —
adapted for a cross-platform Tauri + Next.js app that also runs on iOS, Android, web, and
e-ink readers.
What we take from Adwaita:
- **Content first, chrome recedes.** The reading surface is the product. Settings, toolbars,
popups never compete with the page.
- **Boldly minimal.** Restraint over density. Whitespace is structural.
- **Surface hierarchy** — window → view → card — three explicit elevation tiers, no shadow
gymnastics.
- **Color discipline.** Brand color is rare and earned. Neutral palette carries the weight.
- **Boxed lists are the chassis.** AdwActionRow's prefix · title · suffix anatomy is the
canonical settings/list row everywhere.
- **Pills, ghosts, flats.** Three-tier button palette: pill/circular ghost in headers, flat
secondary over view-bg, accent CTA only when truly primary.
- **Banner vs Toast.** AdwBanner = inline, top-of-window, persistent. AdwToast = transient,
bottom slide-in.
- **Switches over checkboxes** for boolean settings.
- **Subtle motion.** Short, ease-out, never bouncy.
What's Readest-specific:
- **E-ink as a first-class mode.** Every surface flips to flat 1px contrast borders under
`[data-eink='true']`. Adwaita is desktop-GNOME-only; we ship to e-ink readers and the
visual language has to survive there.
- **Cross-platform reality.** Readest runs on macOS, Windows, Linux, iOS, Android, web. The
identity stays Adwaita; platform grace notes (radii, target sizes) follow host
conventions where they matter.
---
### 2. Principles
The seven rules. When in doubt, work backward from these.
#### 2.1 Surfaces continue surfaces
A control that extends a list/card should match its parent's border + fill. The
"+ Import Dictionary" button at `src/components/settings/CustomDictionaries.tsx` reads as
detached card siblings of the dictionary list above it because they share
`border-base-200 bg-base-100 rounded-lg`.
> **Bad**: a list of dictionaries in a `bg-base-100` card, followed by a `btn-outline btn-primary`
> add button. The button shouts; the list whispers; the eye bounces.
>
> **Good**: list and add-button share the same surface vocabulary. The eye flows.
#### 2.2 Color is earned
Brand `primary` is reserved for **the** primary action of a surface. Most actions don't have
a primary action — they have a list of equally-weighted choices, or a single accent.
- Settings dialog has no primary. Every panel is a list of toggles. **Zero brand color.**
- "Import a Book" in onboarding is a primary CTA. **One brand color.**
- "Add Web Search" extends a list — it's not the surface's primary action. **Neutral.**
#### 2.3 Two-step depth
State changes cycle through **`base-100 → base-200 → base-300`** instead of recoloring.
Hover lifts, active deepens, disabled fades opacity. This is theme-safe (works across all
11 color themes), e-ink-friendly (depth is preserved as borders, not shades), and
calmer than recoloring.
#### 2.4 Localize the hover signal
When a button hovers, **one focal element changes**, not the whole button. The icon chip
inverts; the label stays steady. The badge intensifies; the row stays neutral. This reads
as deliberate, not decorative.
#### 2.5 Motion is color, not transform
Default to `transition-colors duration-150`. No `scale`, no `translate`, no `rotate` unless
the motion **is** the message (a chevron rotating to indicate expansion is fine; a button
that scales on hover is not). Transforms break under `[data-eink='true']` and feel
gimmicky under Adwaita's calm rhythm.
#### 2.6 Eink-first by default
Every custom-styled bordered surface gets the `eink-bordered` class. Every primary action
gets `btn-primary` (which has dedicated eink rules). Don't rely on color or shadow alone
for hierarchy — eink screens have neither.
If you can't toggle Settings → Misc → Eink and still tell which button is the CTA, the
hierarchy is broken.
#### 2.7 Focus is visible but quiet
Keyboard focus needs a visible ring. `focus-visible:ring-2 focus-visible:ring-base-content/15`
is the canonical treatment for custom buttons. Loud `ring-primary` reserved for inputs
where the focus state IS the affordance.
#### 2.8 RTL: always use logical properties (REQUIRED)
Readest ships with RTL languages enabled. **Never use direction-bound Tailwind
utilities** when a logical equivalent exists — the visual edges flip in RTL,
the logical ones don't.
| Don't use | Use instead |
| ---------------------------------- | ---------------------------------- |
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
| `ml-*` / `mr-*` | `ms-*` / `me-*` |
| `text-left` / `text-right` | `text-start` / `text-end` |
| `border-l` / `border-r` | `border-s` / `border-e` |
| `rounded-l-*` / `rounded-r-*` | `rounded-s-*` / `rounded-e-*` |
| `left-*` / `right-*` (positioning) | `start-*` / `end-*` |
| `justify-start` / `justify-end` | (these ARE direction-aware) — keep |
The `flex-row` direction is automatically reversed in RTL by the browser, so
you usually don't need to do anything for `flex` / `gap`. Only **explicit
edges** (padding, margin, borders, radius, absolute positioning) need
logical properties.
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
case (rare — usually only icon glyphs that have a fixed orientation) should
be flipped to the logical equivalent.
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
Every settings panel and every sub-page must open with:
1. **A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
top-level panel this is an `<h2>`; in a sub-page this is the `parentLabel /
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
word stays anchored visually as the user navigates in/out).
2. **A one-line description** — a short sentence under the title explaining what this
surface does or how it fits in the user's workflow. Style: `text-sm
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
the breadcrumb already says everything (rare — when in doubt, write one).
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
both). The same vertical opening across every surface makes the system feel cohesive
and gives users a predictable place to learn what a screen does.
**Canonical components.** The `<SubPageHeader>` primitive in
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
prop that renders the description in the canonical style — sub-pages should pass it
there rather than rolling their own `<p>` below the header. Top-level panels currently
inline the title + description; if a third or fourth panel needs the same pattern,
extract a `<PanelHeader>` primitive following the same shape.
**Examples.**
```tsx
// Sub-page (Integrations → OPDS Catalogs)
<SubPageHeader
parentLabel={_('Integrations')}
currentLabel={_('OPDS Catalogs')}
description={_('Browse and download books from online catalogs')}
onBack={() => setSubPage(null)}
/>
// Top-level panel (Integrations panel root)
<div className='w-full'>
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
<p className='text-base-content/70 text-sm leading-relaxed'>
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
</p>
</div>
```
---
### 3. Surface hierarchy
Three named tiers, mapped onto daisyui tokens. Use these terms in conversation and code
comments even though the classes are still daisyui-native.
| Tier | Token | Role | Example |
| ---------- | ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- |
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `<Dialog>` body |
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
Border treatment:
- **Window** has no border (it IS the boundary).
- **View** uses no border or `border-base-200/60` for very soft delineation.
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
`border-base-content`.
Corner radius:
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
close enough and matches Tailwind's scale.
- **Modal / Sheet**: `modal-box` default (~1rem / 16px) — bigger surfaces get bigger radii.
- **Pills / Chips**: `rounded-full`.
- **Inputs / small buttons**: `rounded-md` (6px) or `rounded-lg` (8px).
#### Surface continuity rule
When a control extends a card (an "add row" affordance, a footer button bar attached to a
list), it inherits the card's surface treatment: same `bg-base-100`, same
`border-base-200`, same `rounded-lg`. It is the card grown by one row.
---
### 4. Action vocabulary
Six archetypes. Pick by **role**, not by **appearance**.
#### 4.1 Accent CTA
The primary, accent-colored button. **One per surface, max.** Submit on a form, "Open
Book", "Sign In".
```tsx
className = 'btn btn-primary';
```
Eink: `btn-primary` has dedicated rules (inverts to base-content bg + base-100 text) so it
stays distinct from secondary actions on monochrome screens.
#### 4.2 Suggested
A non-accent-but-emphasized action. Used when there are multiple equally-weighted actions
and one is the recommended path. Adwaita's "suggested-action" CSS class.
```tsx
className = 'btn btn-neutral';
```
Rare. Most surfaces don't need this tier.
#### 4.3 Flat
The default secondary button. Sits on a view or card surface, no border, hover lifts to
`base-200`. The bulk of buttons should be flat.
```tsx
className="btn btn-ghost"
// or for a custom surface treatment:
className={clsx(
'rounded-lg px-4 py-2 text-sm font-medium',
'hover:bg-base-200 transition-colors duration-150',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
)}
```
#### 4.4 Pill / Circular ghost
Compact icon-only buttons in header bars and toolbars. Always `rounded-full`,
`btn-circle` or hand-rolled circular ghost.
```tsx
className = 'btn btn-ghost btn-circle h-8 min-h-8 w-8 p-0';
```
The window controls in `SettingsDialog.tsx` (search, menu, close) use this archetype.
#### 4.5 Destructive
Delete, remove, irreversible. Adwaita uses `destructive-action`. Readest uses red
sparingly — usually only the icon, not the whole button.
```tsx
// Icon-only delete X in delete mode:
className = 'btn btn-ghost btn-sm shrink-0 px-1';
// with <IoMdCloseCircleOutline className="text-error h-4 w-4" />
```
For destructive **dialogs** (confirmation modals), the confirm button can be `btn-error`,
but only in the modal — never on the main surface.
#### 4.6 ListExtension
A Readest-named archetype for "add another row to the list above" affordances. The two
buttons at the bottom of `CustomDictionaries.tsx` are the canonical example.
Anatomy:
- Surface matches the parent card (`border border-base-200 bg-base-100 rounded-lg`)
- Height ~h-11
- Centered: small icon chip + label
- Icon chip: `bg-base-200 text-base-content/60 rounded-full h-5 w-5`
- Hover: border deepens to `base-300`, bg lightens to `bg-base-200/60`, icon chip inverts
to `bg-base-content text-base-100`
- `eink-bordered` on the button itself
```tsx
<button
type='button'
onClick={handleAdd}
className={clsx(
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
'border-base-200 bg-base-100 rounded-lg border px-4',
'text-base-content text-sm font-medium',
'transition-colors duration-150',
'hover:border-base-300 hover:bg-base-200/60',
'active:bg-base-200/80',
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
)}
>
<span
className={clsx(
'flex h-5 w-5 items-center justify-center rounded-full',
'bg-base-200 text-base-content/60',
'transition-colors duration-150',
'group-hover:bg-base-content group-hover:text-base-100',
)}
>
<MdAdd className='h-3.5 w-3.5' />
</span>
<span className='line-clamp-1'>{label}</span>
</button>
```
Use this for: "Import Dictionary", "Add Web Search", "Add Custom Theme", any "+ add new
to this list" pattern. **Do not** use `btn-outline btn-primary` for these.
---
### 5. Boxed list anatomy
The settings UI is built on boxed lists. One pattern, used everywhere.
#### Container
Use the `<BoxedList>` primitive at `src/components/settings/primitives/BoxedList.tsx`
rather than inlining the chassis classes:
```tsx
<BoxedList title={_('Reading Sync')} data-setting-id='settings.section.id'>
{/* rows */}
</BoxedList>
```
The primitive renders:
```tsx
<div className='card eink-bordered border-base-200 bg-base-100 border'>
<div className='divide-base-200 divide-y'>{children}</div>
</div>
```
- `card` for the radius
- `border border-base-200` for the boundary (eink upgrades this automatically)
- `eink-bordered` for the e-ink-mode contrast border
- `divide-base-200 divide-y` for inter-row separators
> **No `overflow-hidden` on the card.** Children may host popovers (color
> pickers, dropdowns, tooltips) that need to escape the card bounds. The
> `divide-y` rules sit between rows and don't touch the card's rounded
> corners, so omitting overflow-clip is visually safe AND keeps embedded
> popovers from getting clipped.
#### Row anatomy
Three slots, in order, always:
```
┌─────────────────────────────────────────────────────────────────┐
│ [prefix] Title text [suffix slots] │
│ [ ] Subtitle text (optional) [ ][ ]│
└─────────────────────────────────────────────────────────────────┘
```
| Slot | Contents |
| ------------ | ------------------------------------------------------------------------------------------------- |
| **Prefix** | Drag handle, leading icon, avatar, status dot, or empty. |
| **Title** | Primary label. `font-medium`. Truncates with `truncate`. |
| **Subtitle** | Optional secondary line. `text-sm text-base-content/70`. Used for warnings, descriptions, status. |
| **Suffix** | Badge, switch, button, chevron, value, or any combination. End-aligned. |
Canonical example: `SortableRow` in `src/components/settings/CustomDictionaries.tsx`. The
drag handle is the prefix, the dict name is the title, the warning reason is the
subtitle, and the badge + toggle + edit/delete buttons stack as suffixes.
#### Row variants
- **ActionRow** — title + suffix is a single button or chevron. Tap anywhere navigates.
- **SwitchRow** — title + suffix is a toggle. Tap anywhere toggles.
- **ComboRow** — title + suffix is a dropdown/select.
- **ExpanderRow** — chevron suffix; tap expands to reveal nested rows.
These names come from libadwaita and apply 1:1 to Readest's lists. Use the names in code
comments and PR descriptions.
#### Spacing
- Row vertical padding: `py-2` (8px) for compact lists, `py-3` (12px) for breathing room.
- Row horizontal padding: `px-3` (12px) or `px-4` (16px). Stay consistent within a list.
- Slot gap: `gap-2` (8px) between prefix/title/suffix elements.
#### Disabled rows
Disabled rows fade the title to `text-base-content/60` and disable the suffix control. The
row itself stays at full opacity — only the **content** dims, not the row.
#### Toggle size
| Daisyui class | Use case |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `toggle` (default, h-5 / ~20px) | **Settings panel boxed-list rows**`<SettingsSwitchRow>` uses this. Visible weight matches the 56px `min-h-14` row. |
| `toggle-sm` (h-4 / ~16px) | Inline secondary switches in tighter contexts — e.g. dictionary list rows in `CustomDictionaries`. |
| `toggle-xs` (h-3 / ~12px) | Compact metadata toggles inside cards — e.g. OPDS catalog "Auto-download". |
The `<SettingsSwitchRow>` primitive bakes in the default `toggle`. **Don't override
to `toggle-sm` inside boxed-list rows** — it looks orphaned in the row's vertical
breathing room. Use the smaller sizes only when the row itself is shorter than 56px.
#### Typography inherits from `.settings-content`
The Settings dialog (and any settings-style sheet/popup) wraps its content
in `.settings-content`, which is defined in `src/styles/globals.css` as:
```css
.dropdown-content,
.settings-content {
font-size: 14px; /* desktop */
}
@media (max-width: 768px) {
.dropdown-content,
.settings-content {
font-size: 16px; /* mobile bump — high-DPI phones need bigger body text */
}
}
```
**Don't hardcode `text-sm` on row labels, NavigationRow titles, or panel
descriptions** — that locks the text to 14px on every viewport and kills
the mobile bump. Instead:
- **Primary labels** (SettingsRow label, NavigationRow title, SubPageHeader
description, ad-hoc row labels in panels and integration forms): no
font-size class — inherits 14/16 from the wrapper. Use `<SettingLabel>`
rather than inlining a `<span>`; it adds `font-medium` for cased scripts
and drops the weight for caseless scripts (CJK / Arabic / Hebrew / Indic
/ Thai / Tibetan), since those bold poorly at body size and `font-medium`
on Han / Hangul / Devanagari renders as uneven stroke-thickening across
system fonts.
- **Secondary text** (SettingsRow description, NavigationRow status, Tips
body, BoxedList description): use `text-[0.85em]` so it stays
proportional (≈12px desktop, ≈13.6px mobile).
- **Form controls** (`<input>`, `<select>`): browsers don't inherit
font-size onto form elements, so add the `settings-content` class
_directly on the element_ to re-apply the 14/16 cascade. The legacy
NumberInput already does this — match its pattern.
- **Section headers** (`BoxedList` uppercase title): use `text-[0.85em]
font-semibold uppercase tracking-wider`. The em-relative size keeps it
proportional with the `.settings-content` cascade. **Caseless-script
exception:** when `isCaselessUILang()` is true, bump to `text-[1em]`.
The `uppercase` rule is a no-op in scripts without case (CJK, Arabic,
Hebrew, Devanagari/Bengali/Tamil/Sinhala, Thai, Tibetan), so the size
has to carry the emphasis those scripts can't pick up from casing. The
helper lives in `src/utils/misc.ts`; the underlying `isCaselessLang`
predicate lists every covered language code in `src/utils/lang.ts`.
Why this matters: Tailwind's `text-xs` / `text-sm` are rem-based — they
ignore the parent's `font-size` because rem is rooted at the document.
The `.settings-content` cascade is in `px`, so any child that picks a
Tailwind size literally tunes itself to the desktop default and never
grows on mobile. iOS and Android have small physical screens but high
DPI, so the mobile bump is what makes the text legible at typical reading
distance.
#### Uniform row height
Settings rows in a boxed list MUST all be the same visual height. Use
`min-h-14 items-center` (56px) on each row container — toggle, select, and
input rows then center their controls vertically inside identical boxes.
**Don't use `py-3`** — content-driven padding produces uneven heights
because toggles, selects (`h-9`), and inputs (`h-9`) have different
intrinsic sizes.
```tsx
// ✓ Right — no text-sm; label inherits .settings-content (14/16)
<label className='flex min-h-14 items-center justify-between px-4'>
<span className='font-medium'>{_('Sync Enabled')}</span>
<input type='checkbox' className='toggle' ... />
</label>
// ✗ Wrong — toggle row will be 48px, select rows 60px
<label className='flex items-center justify-between px-4 py-3'>...</label>
// ✗ Wrong — text-sm hardcodes 14px even on mobile (kills the bump)
<span className='text-sm font-medium'>{_('Sync Enabled')}</span>
```
#### Controls inside a boxed list have no chrome
When a control sits inside a bordered card, it shouldn't carry its own
border or fill. The card supplies the visual boundary; the control just
sits on the row.
- **Selects:** drop `select-bordered` and `eink-bordered`. Add
`!bg-transparent !bg-none !appearance-none` to suppress daisyui's
background chevron and native arrow. Render a real `<MdArrowDropDown>`
icon at the cell's trailing edge for the affordance — see "End-aligned
values" below.
- **Inputs:** drop `input-bordered` and `eink-bordered`. Add `!bg-transparent`
with `hover:!bg-base-200/60 focus:!bg-base-200/60` so the field still
signals interactability. Use `text-end` and `!pe-0` so the value sits
flush against the row's trailing edge.
- **Toggles:** untouched — they're already chromeless.
This is the iOS Settings / Adwaita PreferencesGroup convention: list
chrome belongs to the container, not its children.
#### End-aligned values + chevron alignment
The selected value of a select/input MUST end-align (`text-end`). The
**visible right edge** of every row's value (toggle, chevron icon, input
text) MUST land at the same X — the row's trailing padding.
The trap: daisyui's select renders its chevron via background-image at
`calc(100% - 1rem) center`, which floats the glyph 16px _inside_ the
select's right edge. So if the toggle in row 1 ends at the row's `pe-4`
edge, the chevron in row 2 ends 16px before that — visibly misaligned.
**Fix:** suppress daisyui's bg-image chevron and render an explicit icon at
the cell's trailing edge. The select's own daisyui focus chrome (outline +
box-shadow + ring) is suppressed; **no focus ring** on controls inside the
boxed list — focus state is signaled by a subtle wrapper bg-shift instead
(hover and focus-within both lift to `bg-base-200/60`). Rings would compete
with the card's own border and double-stack with adjacent rows.
```tsx
<div className='hover:bg-base-200/60 focus-within:bg-base-200/60 flex max-w-[60%] items-center rounded-md'>
<select className='select h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'>
{/* options */}
</select>
<MdArrowDropDown
aria-hidden='true'
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
/>
</div>
```
> **Why so many `!` overrides?** daisyui's `.select` and `.input` apply
> `border-width: 1px` + `border-color` (transparent at rest, `var(--bc)` on
> focus), plus `outline`, `box-shadow`, and `ring` chrome on focus. To make
> the control truly chromeless inside a boxed list, you need to kill all
> four properties. Missing any of them — especially `border-0` — leaves a
> visible focus border leaking through.
The `<MdArrowDropDown>` icon's trailing edge now lives at the same X as the
toggle's trailing edge in adjacent rows, because both are flush with the
row's `pe-4` padding.
For inputs, no wrapper is needed — the input is one element, so put the
hover/focus bg directly on it. Suppress daisyui's own focus chrome the
same way:
```tsx
<input className='input hover:!bg-base-200/60 focus:!bg-base-200/60 h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-0 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0' />
```
> **Why no ring here when §2.7 says "focus needs a visible ring"?** §2.7 is
> for standalone custom buttons (Submit, Cancel, ListExtension, etc.). In a
> boxed list, the row already provides strong visual containment via the
> card border + dividers, and stacking a per-control ring inside that
> creates double chrome. The bg-shift IS the focus indicator — keyboard
> users still get clear feedback; the surface stays calm.
---
### 6. Header bars, dialogs, popups, sheets
#### Header bar
The dialog/page header. Adwaita's AdwHeaderBar.
- **4856px tall** (`h-12` to `h-14`).
- **Center-aligned title** in `font-semibold text-base`.
- **Leading slot**: back chevron (mobile) or empty (desktop).
- **Trailing slot**: window controls — search (pill ghost), menu (pill ghost),
close (pill ghost circle with `bg-base-300/65`).
- No bottom border; rely on tab/divider that follows.
`SettingsDialog.tsx`'s mobile header is the canonical example. The desktop header is
slightly different — tabs sit in the same row as window controls, no center title — but
it's the same archetype adapted for screen real estate.
#### Dialog (modal)
```tsx
<Dialog
isOpen={...}
onClose={...}
boxClassName="sm:min-w-[520px] overflow-hidden"
header={<HeaderBar />}
>
{/* content */}
</Dialog>
```
- `modal-box` provides the radius, max-width, and shadow (auto-removed in eink).
- Width ~520px on desktop, full-width on mobile.
- Bottom sheets on mobile via `snapHeight` prop.
- Backdrop: `sm:!bg-black/50` (or `/20` when nested over a darker surface).
#### Popup (popover)
For dictionary lookups, annotation editors, and other anchored overlays. Uses the
`Popup` component with a triangle pointer.
- **Width**: clamp to fit content; ~320420px typical.
- **Surface**: `bg-base-100`, `rounded-lg`, soft shadow (eink removes shadow).
- **Triangle**: pointer toward the anchor; eink has special triangle classes.
- **Padding**: `p-3` to `p-4` for content.
#### Sheet (mobile bottom)
Reserved for mobile contextual menus and full-screen secondary panels. Uses the dialog's
`snapHeight` prop. Adwaita doesn't have a native sheet but Readest's mobile pattern is
the closest analog.
- Always full-width.
- Top corners rounded; bottom corners flat (it's anchored to the bottom).
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
swipe-to-dismiss.
---
### 7. Motion + a11y
#### Motion
- Default duration: **150ms** for color transitions.
- Default easing: browser default (`ease`) or `ease-out`. Never `ease-in`.
- Longer transitions (300ms+) only for layout changes (sheet snap, panel slide).
- **Never** use `transform` for hover unless the transform IS the message
(chevron rotation, drag-handle drag visualization). E-ink doesn't render mid-transitions
cleanly and Adwaita's identity is calm.
```tsx
// Good — hover:bg-base-200 with transition-colors
className = 'transition-colors duration-150 hover:bg-base-200';
// Bad — scale on hover
className = 'transition-transform hover:scale-105';
```
Existing exceptions: `.window-button` in globals.css uses `hover:scale-105`. That's
legacy; new code shouldn't follow it.
#### Reduced motion
Reduced-motion preference is honored via the `no-transitions` class
(`globals.css:624`). Layout-changing transitions should respect
`prefers-reduced-motion: reduce` either via this class or `motion-safe:` Tailwind
prefixes.
#### Focus
- Every focusable element must have a visible focus indicator.
- Custom buttons:
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15`.
- Inputs: rely on daisyui's input focus ring; inputs with custom styling use
`focus:ring-2 focus:ring-primary/40`.
- Don't use `outline-none` without `focus-visible:` replacement.
#### Hit targets
- **Minimum**: 32px (the size of `btn-sm`).
- **Recommended**: 40px (`btn`) on touch surfaces.
- **Mobile**: 44px+ for taps that aren't fail-safe (delete, navigate-away).
- The `touch-target` class in globals.css extends a small visual control's hit area to
44px without changing its rendered size — use it on icon-sized buttons in mobile UIs.
#### Color contrast
- Body text on background: WCAG AA (4.5:1) minimum.
- Large text: WCAG AA Large (3:1) minimum.
- Interactive text on hover state: still passes contrast on the new background.
- Theme palette is generated from `(bg, fg, primary)`; the tinycolor pipeline keeps
contrast within range, but custom themes can break this — Settings → Color flags
low-contrast custom themes.
#### Keyboard
- Tab order matches visual order. If you use `flex-row-reverse` for visual layout,
consider `tabIndex` to fix order.
- Modal focus trap: `<Dialog>` handles this.
- Esc to dismiss: `<Dialog>` and `<Popup>` handle this.
- Arrow keys for grouped controls (radio-like tab strips, sortable lists). dnd-kit's
`KeyboardSensor` is wired for sortable lists.
---
### 8. E-ink overlay (cross-cutting)
E-ink mode is toggled by `[data-eink='true']` on the document. It applies a global
override layer in `src/styles/globals.css:484-622` that:
- Removes all `box-shadow`.
- Forces `text-base-content`, `text-blue-*`, `text-red-*`, `text-neutral-content` to a
single foreground color.
- Inverts `btn-primary` and `btn-outline` to base-content bg + base-100 text.
- Adds 1px contrast borders to `.eink-bordered`, `.modal-box`, `.menu-container`,
`.popup-container`, `.alert`, `.opds-navigation .card`, `.booknote-item`,
`.bookitem-main`.
What this means for new components:
| Surface type | Required class | Why |
| ------------------------------------ | ----------------------- | ------------------------------------------------------------- |
| Custom bordered button or input | `eink-bordered` | Gets the 1px contrast border in eink |
| Primary CTA | `btn-primary` | Picks up the inverted treatment |
| Cancel / secondary action | `btn-ghost` (no border) | Reads as "outlined" only after pairing with the CTA |
| Card / panel using `border-base-200` | `eink-bordered` | Otherwise the soft border vanishes in eink |
| Modal / Popup | (auto) | `modal-box` and `.popup-container` are handled in globals.css |
Verification checklist before shipping a new UI:
- [ ] Toggle Settings → Misc → Eink mode and re-test every screen.
- [ ] Every container that has a soft border (`border-base-200`) still has visible
delineation.
- [ ] Every CTA is distinguishable from its neighbors (cancel, secondary).
- [ ] No hover transforms make the UI feel jumpy.
- [ ] Text is fully opaque (no `text-base-content/60` content; eink can't render the
reduced opacity well).
#### What's NOT compatible with e-ink
- Drop shadows for hierarchy (use borders).
- Color-only state changes (use border weight or fill swap).
- Hover scale / translate (they look broken on slow refresh).
- Animations longer than ~200ms (visible refresh artifacts).
---
### 9. Cross-platform grace notes
Readest ships on **macOS, Windows, Linux, iOS, Android, web**. Adwaita is desktop-GNOME-
native; we adapt where the host OS has strong conventions, but never at the cost of
identity.
#### iOS
- Slightly larger corner radii feel native (`rounded-xl` on dialogs, `rounded-lg` on
cards).
- Safe area insets are mandatory for top + bottom anchored elements (see
`docs/safe-area-insets.md`).
- Avoid Material Design ripple effects.
- Sheet-style modals (bottom-anchored) match iOS conventions and are preferred over
centered dialogs on phone-sized screens.
#### Android
- Material 3 conventions that conflict with Adwaita (FABs, elevation shadows, ripple
inks): **don't** copy them. Readest's identity is Adwaita; the user is reading on
Android, not in Android.
- Touch targets bumped to 48px for primary actions (Material's recommended target).
- Back-gesture-aware UIs: ensure swipe-from-edge doesn't conflict with horizontal swipe
controls.
#### Linux
- Native Adwaita territory. Readest can match host theme for window chrome (Tauri
decorations) but should keep its own internal palette for the reading surface — book
themes (sepia, gruvbox, etc.) are user choices, not OS choices.
#### macOS / Windows
- Window controls (close/minimize/maximize) are platform-native via Tauri.
- Title bar height matches platform convention; internal layout follows Readest's
Adwaita palette.
#### Web
- No safe-area insets needed.
- Keyboard shortcuts are doubled with command-palette discoverability (Cmd/Ctrl+K).
- Browser-native focus rings: respected, augmented with `focus-visible:ring-*`.
#### E-ink readers (Android-based, custom firmware)
- Detected via the eink mode toggle (Settings → Misc).
- All rules in §8 apply.
- This is a **first-class** target, not a fallback.
---
### 10. Anti-patterns
Things that LOOK fine in isolation but break the system. Each one has a real source diff
or commit reference.
#### 10.1 Loud outlined CTAs for non-primary actions
```tsx
// Anti-pattern (was in CustomDictionaries.tsx, fixed Nov 2026):
<button className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'>
<MdAdd className='h-5 w-5' />
Import Dictionary
</button>
// Correct: ListExtension archetype (see §4.6)
```
Why it broke: the buttons read as primary CTAs but are list extensions. They competed
with the active settings tab indicator and pulled the eye from the list itself.
#### 10.2 Recoloring the whole button on hover
```tsx
// Anti-pattern:
<button className="text-base-content/70 hover:text-base-content hover:bg-primary/10">
// Correct: keep the label color steady, hover via bg shift on the surface
<button className="text-base-content hover:bg-base-200 transition-colors">
```
Why: principle 2.4 (localize the hover signal). Whole-button color shifts feel decorative.
#### 10.3 Transform-based hover
```tsx
// Anti-pattern:
<button className="hover:scale-105 transition-transform">
// Correct: color/border-based hover
<button className="hover:bg-base-200 hover:border-base-300 transition-colors">
```
Why: breaks under e-ink (§2.5), feels jumpy under Adwaita's calm rhythm.
#### 10.4 Soft borders without `eink-bordered`
```tsx
// Anti-pattern:
<div className="border border-base-200 bg-base-100 rounded-lg p-4">
...
</div>
// Correct:
<div className="eink-bordered border border-base-200 bg-base-100 rounded-lg p-4">
...
</div>
```
Why: in e-ink mode, `base-200` borders disappear into the background. `eink-bordered`
flips the border to `base-content` so the boundary stays visible.
Exception: containers that **don't** need a visible boundary in eink (e.g., a
`bg-base-100` surface that's already against `bg-base-200`) can skip `eink-bordered`.
The class is opt-in for "this surface needs a border to read correctly".
#### 10.5 Reduced-opacity text in e-ink
```tsx
// Anti-pattern (in eink):
<span className="text-base-content/50">Optional metadata</span>
// Correct (still readable in eink):
<span className="text-base-content text-xs">Optional metadata</span>
// Or use semantic muting that the eink overlay handles:
<span className="text-neutral-content">Optional metadata</span>
```
Why: e-ink's reduced color depth turns `/50` opacity into illegible mush. Use size or
weight for hierarchy on muted secondary text.
#### 10.6 Daisyui `btn` defaults without intent
```tsx
// Anti-pattern: just reaching for `btn` with no role:
<button className="btn">Click me</button>
// Correct: pick an archetype from §4.
<button className="btn btn-ghost">Cancel</button> // Flat
<button className="btn btn-primary">Save</button> // Accent CTA
```
Why: daisyui's `btn` default isn't tuned for any specific role. Pick from the action
vocabulary so the button signals its weight in the surface hierarchy.
#### 10.7 Ad-hoc surface tokens
```tsx
// Anti-pattern:
<div className="bg-white border-gray-200">
// Correct:
<div className="bg-base-100 border-base-200">
```
Why: hard-coded colors don't theme. Readest has 11 themes plus user-defined custom themes.
Always use the daisyui semantic tokens.
#### 10.8 Mixing `btn` sizes within a surface
```tsx
// Anti-pattern:
<header>
<button className="btn btn-sm">Search</button>
<button className="btn btn-md">Settings</button>
<button className="btn btn-xs">Close</button>
</header>
// Correct: one size per surface
<header>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Search</button>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Settings</button>
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Close</button>
</header>
```
Why: visual rhythm. Mixed sizes feel like the surface is unfinished.
---
### 11. Quick reference
When designing a new surface, walk this checklist:
1. **What's the surface tier?** Window / View / Card. (§3)
2. **What's the corner radius?** Match the tier. (§3)
3. **Is there a primary action?** If yes, ONE accent CTA. If no, all flats. (§4.1, §4.3)
4. **Are there list extensions?** Use the ListExtension archetype, not `btn-outline btn-primary`. (§4.6)
5. **Is it a list?** Use the BoxedList chassis with ActionRow / SwitchRow / ComboRow / ExpanderRow rows. (§5)
6. **Does it need `eink-bordered`?** If it has a soft border that must stay visible in
eink mode, yes. (§8)
7. **Is the hover signal localized?** One focal element changes, not the whole control. (§2.4)
8. **Is motion color-only?** No transforms unless the transform IS the message. (§2.5)
9. **Is focus visible?** `focus-visible:ring-2 focus-visible:ring-base-content/15` on
custom buttons. (§7)
10. **Will it work on the smallest theme + e-ink?** Toggle Sepia + Eink, retest.
---
### 12. Glossary
- **Adwaita / libadwaita**: GNOME's design system and widget toolkit. Source of Readest's
visual lineage.
- **AdwActionRow / AdwSwitchRow / AdwComboRow / AdwExpanderRow**: libadwaita's row
primitives. Readest mirrors these conceptually with custom React components.
- **AdwBoxedList**: libadwaita's named container for grouped action rows.
- **AdwBanner**: top-of-window inline alert (persistent).
- **AdwToast**: bottom slide-in transient alert.
- **Window / View / Card**: surface tiers (§3).
- **ListExtension**: Readest-named archetype for "+ add new row" buttons (§4.6).
- **eink-bordered**: utility class in `globals.css` that gives a surface its e-ink-mode
contrast border. Opt-in.
- **Pill ghost**: circular icon button, `btn-ghost btn-circle`.
---
### 13. Maintenance
This doc is the **source of truth** for new design decisions. When the system grows:
- New archetypes get a numbered subsection in §4 or §5.
- New anti-patterns get added to §10 with a real source reference.
- Updates to existing principles require a brief why-changed note in the relevant section.
Cross-references that must stay in sync:
- `CLAUDE.md` E-ink mode section → §8 of this doc.
- `docs/safe-area-insets.md` → §9 (cross-platform).
- `src/styles/globals.css` `[data-eink]` rules → §8.
- `src/styles/themes.ts` Palette type → §3 token table.
If you change a rule here, search for the cross-reference and update both.
-57
View File
@@ -1,57 +0,0 @@
# Benchmarks
Manual performance benchmarks for the readest-app. **Not run in CI** — CI runners
have shared-tenant variance that makes performance regression detection unreliable
(numbers swing 2-10× between runs). These exist so anyone considering an
architecture change can produce reproducible before/after numbers on their own
hardware.
## Run
```bash
pnpm bench # run every bench/*.bench.ts
pnpm bench vector-retrieval # run a single benchmark by name
pnpm bench --no-record # run but don't append to bench/results.jsonl
pnpm bench --list # list available benchmarks
```
Refuses to run when `$CI` is set. Append `--force` to override (don't unless
you've explicitly opted into running benches in CI for a one-off investigation).
## Output
Each run prints a header with machine info (platform, CPU, Node version, key
package versions) followed by per-benchmark results. By default, results are
also appended to `bench/results.jsonl` (gitignored) — your personal local
history. To share numbers, paste the table from the terminal into a PR or issue.
## When to add a new benchmark
When you're proposing an architecture change and need numbers to defend it. The
benchmark should:
1. Live at `bench/<name>.bench.ts`.
2. Export `default { name, description, run(ctx) }` matching the type in `lib.ts`.
3. Print human-readable results to stdout and return structured results to the
harness so they get logged to `results.jsonl`.
4. Be self-contained — no fixtures outside `bench/`, no I/O outside the bench
directory and an in-memory database.
5. Run in under ~30 seconds at default sample sizes. If you need long-running
scenarios, gate them behind a CLI flag.
## When *not* to add a benchmark
- "Just in case" — performance infrastructure has carrying cost. Wait until
you have a real architecture question that numbers will answer.
- To benchmark upstream libraries' performance (e.g., raw Turso function
throughput). That belongs in the upstream project's bench suite.
- To gate CI on performance thresholds. CI variance makes that flaky; use
production telemetry (`reedy_metrics` table) for regression detection
against real workloads.
## Existing benchmarks
- **`vector-retrieval`** — proves Turso's brute-force vector search is
SIMD-accelerated and fast enough for Reedy MVP corpus sizes (sub-millisecond
at 400 chunks × 768 dim, ~14 ms at 10K chunks × 768 dim). Established the
decision in plan §M1.5 to skip ANN indexes (which Turso doesn't ship anyway).
-115
View File
@@ -1,115 +0,0 @@
import { readdirSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { type Bench, type BenchResult, formatHeader, formatResults, machineInfo } from './lib.ts';
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
const RESULTS_FILE = join(BENCH_DIR, 'results.jsonl');
interface Args {
filter: string | null;
record: boolean;
list: boolean;
force: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { filter: null, record: true, list: false, force: false };
for (const arg of argv) {
if (arg === '--no-record') args.record = false;
else if (arg === '--list') args.list = true;
else if (arg === '--force') args.force = true;
else if (!arg.startsWith('--')) args.filter = arg;
else {
console.error(`Unknown flag: ${arg}`);
process.exit(2);
}
}
return args;
}
async function loadBenches(): Promise<Bench[]> {
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith('.bench.ts'));
const benches: Bench[] = [];
for (const file of files) {
const mod = await import(resolve(BENCH_DIR, file));
const bench = mod.default as Bench;
if (!bench || typeof bench.run !== 'function') {
console.error(`Skipping ${file}: no default export with .run()`);
continue;
}
benches.push(bench);
}
return benches.sort((a, b) => a.name.localeCompare(b.name));
}
function gitCommit(): string {
try {
return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
function recordRun(bench: Bench, results: BenchResult[], commit: string): void {
if (!existsSync(BENCH_DIR)) mkdirSync(BENCH_DIR, { recursive: true });
const entry = {
ts: new Date().toISOString(),
commit,
bench: bench.name,
platform: process.platform,
arch: process.arch,
node: process.version,
results,
};
appendFileSync(RESULTS_FILE, JSON.stringify(entry) + '\n');
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (process.env['CI'] && !args.force) {
console.error('Refusing to run benchmarks in CI (CI=' + process.env['CI'] + ').');
console.error('Pass --force if you really mean it. See bench/README.md for why.');
process.exit(1);
}
const benches = await loadBenches();
if (args.list) {
console.log('Available benchmarks:');
for (const b of benches) console.log(` ${b.name.padEnd(20)} ${b.description}`);
return;
}
const selected = args.filter ? benches.filter((b) => b.name === args.filter) : benches;
if (selected.length === 0) {
console.error(`No benchmark named "${args.filter}". Try --list.`);
process.exit(2);
}
const info = machineInfo(['@tursodatabase/database', '@readest/turso-database-wasm']);
console.log(formatHeader(info));
const commit = gitCommit();
console.log(` git commit : ${commit}`);
console.log(` recording : ${args.record ? RESULTS_FILE : 'disabled (--no-record)'}`);
console.log('═'.repeat(70));
for (const bench of selected) {
process.stdout.write(`Running ${bench.name}... `);
const t0 = performance.now();
const results = await bench.run({ verbose: false });
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
console.log(`done in ${elapsed}s`);
console.log(formatResults(bench.name, results));
if (args.record) recordRun(bench, results, commit);
}
console.log('');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
-119
View File
@@ -1,119 +0,0 @@
import { performance } from 'node:perf_hooks';
import { cpus, platform, arch, totalmem } from 'node:os';
import { readFileSync } from 'node:fs';
export interface BenchContext {
/** Whether to print verbose per-iteration info. */
verbose: boolean;
}
export interface BenchResult {
scenario: string;
unit: 'ms' | 'us' | 'ns';
value: number;
/** Optional metadata: chunk count, dim, etc. */
meta?: Record<string, string | number>;
}
export interface Bench {
name: string;
description: string;
run(ctx: BenchContext): Promise<BenchResult[]>;
}
/** High-resolution timer; returns elapsed milliseconds. */
export async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; ms: number }> {
const t0 = performance.now();
const result = await fn();
const ms = performance.now() - t0;
return { result, ms };
}
/** Run `fn` `reps` times, return average milliseconds (after warmup). */
export async function avg(fn: () => Promise<unknown>, reps: number, warmup = 3): Promise<number> {
for (let i = 0; i < warmup; i++) await fn();
const t0 = performance.now();
for (let i = 0; i < reps; i++) await fn();
return (performance.now() - t0) / reps;
}
/** Generate a random unit vector serialized as JSON, suitable for `vector32(?)`. */
export function randomUnitVectorJson(dim: number): string {
const v = new Float32Array(dim);
let norm = 0;
for (let i = 0; i < dim; i++) {
const x = Math.random() * 2 - 1;
v[i] = x;
norm += x * x;
}
const inv = 1 / Math.sqrt(norm);
for (let i = 0; i < dim; i++) v[i] = (v[i] ?? 0) * inv;
return JSON.stringify(Array.from(v));
}
export interface MachineInfo {
platform: string;
arch: string;
cpu: string;
cpuCount: number;
memGiB: number;
node: string;
packages: Record<string, string>;
}
export function machineInfo(packages: string[] = []): MachineInfo {
const cpuList = cpus();
const firstCpu = cpuList[0];
const versions: Record<string, string> = {};
for (const name of packages) {
try {
const pkg = JSON.parse(readFileSync(`./node_modules/${name}/package.json`, 'utf8'));
versions[name] = pkg.version;
} catch {
versions[name] = 'not installed';
}
}
return {
platform: platform(),
arch: arch(),
cpu: firstCpu?.model.trim() ?? 'unknown',
cpuCount: cpuList.length,
memGiB: Math.round(totalmem() / 1024 ** 3),
node: process.version,
packages: versions,
};
}
export function formatHeader(info: MachineInfo): string {
const lines = [
'═'.repeat(70),
` Platform : ${info.platform}/${info.arch}`,
` CPU : ${info.cpu} (${info.cpuCount} cores)`,
` Memory : ${info.memGiB} GiB`,
` Node : ${info.node}`,
];
for (const [name, ver] of Object.entries(info.packages)) {
lines.push(` ${name.padEnd(9)}: ${ver}`);
}
lines.push('═'.repeat(70));
return lines.join('\n');
}
export function formatResults(benchName: string, results: BenchResult[]): string {
const lines = [`\n[${benchName}]`];
for (const r of results) {
const metaStr = r.meta
? ` ${Object.entries(r.meta)
.map(([k, v]) => `${k}=${v}`)
.join(' ')}`
: '';
lines.push(` ${r.scenario.padEnd(40)} ${formatValue(r.value, r.unit).padStart(12)}${metaStr}`);
}
return lines.join('\n');
}
function formatValue(value: number, unit: 'ms' | 'us' | 'ns'): string {
if (unit === 'ms') return `${value.toFixed(3)} ms`;
if (unit === 'us') return `${value.toFixed(2)} µs`;
return `${value.toFixed(0)} ns`;
}
@@ -1,87 +0,0 @@
import { connect } from '@tursodatabase/database';
import { avg, randomUnitVectorJson, type Bench, type BenchResult } from './lib.ts';
/**
* Vector-retrieval brute-force kNN benchmark.
*
* Reedy MVP retrieval (see plan §M1.5) issues:
*
* SELECT id, vector_distance_cos(embedding, vector32(?)) AS d
* FROM reedy_book_chunk_embeddings
* WHERE book_hash = ?
* ORDER BY d ASC LIMIT k
*
* Why this matters: Turso has no native vector index module
* (`libsql_vector_idx` / `vector_top_k` don't exist — confirmed against
* @tursodatabase/database@0.6.0-pre.28 and acknowledged upstream:
* tursodatabase/turso#832 closed not-planned, #3778 proposed brute-force-first
* which shipped at commit 1aba105df4f). The brute-force path with
* SIMD-accelerated `vector_distance_cos` is what we ship; this bench tracks
* its per-query latency at realistic MVP corpus sizes.
*
* Run it after upgrading @tursodatabase/database, after touching
* BookRetriever's SQL shape, or when evaluating an architecture change
* (ANN extension, quantization, engine swap).
*/
export default {
name: 'vector-retrieval',
description: 'Brute-force per-book kNN over vector32 embeddings filtered by book_hash.',
async run(): Promise<BenchResult[]> {
const db = await connect(':memory:', {});
await db.exec(
'CREATE TABLE c (id INTEGER PRIMARY KEY, book_hash TEXT NOT NULL, embedding BLOB)',
);
await db.exec('CREATE INDEX idx_c_book ON c(book_hash)');
// (dim, chunks-per-book) matrix. Two books per scenario so the WHERE filter
// does real work; we measure only the active-book query.
const scenarios = [
{ dim: 384, chunks: 400 }, // small book, light embedding (e5-small-v2)
{ dim: 768, chunks: 400 }, // typical novel @ nomic-embed-text
{ dim: 768, chunks: 2000 }, // long novel
{ dim: 768, chunks: 10000 }, // multi-volume / textbook
{ dim: 1536, chunks: 400 }, // text-embedding-3-small
];
const results: BenchResult[] = [];
for (const { dim, chunks } of scenarios) {
await db.exec('DELETE FROM c');
const insertA = await db.prepare(
"INSERT INTO c (book_hash, embedding) VALUES ('book_a', vector32(?))",
);
for (let i = 0; i < chunks; i++) await insertA.run(randomUnitVectorJson(dim));
const insertB = await db.prepare(
"INSERT INTO c (book_hash, embedding) VALUES ('book_b', vector32(?))",
);
for (let i = 0; i < chunks; i++) await insertB.run(randomUnitVectorJson(dim));
const query = randomUnitVectorJson(dim);
// Embed the query vector literally so SIMD has the same memory layout
// every call (mirrors the BookRetriever code path which serializes the
// query embedding inline at the value-binding position).
const sql = `
SELECT id, vector_distance_cos(embedding, vector32('${query}')) AS d
FROM c
WHERE book_hash = ?
ORDER BY d ASC
LIMIT 5
`;
const stmt = await db.prepare(sql);
const ms = await avg(() => stmt.all('book_a'), 20);
results.push({
scenario: `${chunks} chunks × ${dim} dim`,
unit: 'ms',
value: ms,
meta: { chunks, dim, usPerChunk: ((ms * 1000) / chunks).toFixed(2) },
});
}
await db.close();
return results;
},
} satisfies Bench;
-22
View File
@@ -1,22 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/utils",
"ui": "@/components/ui",
"lib": "@/libs",
"hooks": "@/hooks"
},
"registries": {}
}
-581
View File
@@ -1,581 +0,0 @@
# Readest Architecture
This document gives a system-level view of Readest: how the pieces fit together,
which side of the wire each piece runs on, and what each module is responsible
for. It complements [`code-layout.md`](./code-layout.md), which focuses on the
directory layout. Read this one first if you want to understand the system; read
that one when you need to find a specific file.
The diagrams use [Mermaid](https://mermaid.js.org/) and render natively on
GitHub.
## 1. High-level picture
Readest is a single TypeScript/React codebase (`apps/readest-app`) compiled into
multiple targets:
- a **desktop app** (Windows / macOS / Linux) via Tauri v2
- a **mobile app** (Android / iOS) via Tauri v2 mobile
- a **web app** running on Next.js / Cloudflare Workers (OpenNext) at
[web.readest.com](https://web.readest.com)
- two **side surfaces**: a "Send to Readest" browser extension
(`apps/readest-app/extension/send-to-readest`) and a Windows thumbnail
shell extension (`apps/readest-app/extensions/windows-thumbnail`)
The same React UI runs in all targets. What differs is the **host shell** under
the UI and the **set of services** that the UI binds to at runtime — see
section 4.
```mermaid
flowchart LR
subgraph Clients
Desktop["Desktop app<br/>(Tauri shell + React UI)"]
Mobile["Mobile app<br/>(Tauri Android/iOS + React UI)"]
Web["Web app<br/>(Next.js + React UI)"]
Ext["Browser extension<br/>(Send to Readest)"]
WinExt["Windows shell ext<br/>(thumbnail provider)"]
end
subgraph Backend["Readest backend (Next.js routes + Cloudflare Worker)"]
AppApi["src/app/api/*<br/>(App Router)"]
PagesApi["src/pages/api/*<br/>(Pages Router)"]
RuntimeCfg["/runtime-config.js<br/>(server-injected config)"]
Worker["workers/send-email<br/>(Cloudflare Worker)"]
end
subgraph Cloud["External services"]
Supabase["Supabase<br/>(auth + Postgres)"]
S3["Object storage<br/>(S3 / R2)"]
Stripe["Stripe<br/>(billing)"]
AI["AI providers<br/>(OpenAI / Ollama / ...)"]
Trans["Translators<br/>(DeepL / Google / Azure / Yandex)"]
Meta["Metadata providers<br/>(Google Books / Open Library)"]
Dict["Dictionary sources<br/>(Wikipedia / Wiktionary / StarDict)"]
OPDS["OPDS catalogs / Calibre"]
Hardcover["Hardcover GraphQL"]
Readwise["Readwise"]
TTS["Edge TTS"]
IAP["Apple / Google IAP"]
end
Desktop --> Backend
Mobile --> Backend
Web --> Backend
Ext --> PagesApi
WinExt -.reads files.-> Desktop
PagesApi --> Supabase
PagesApi --> S3
PagesApi --> Trans
AppApi --> Supabase
AppApi --> Stripe
AppApi --> AI
AppApi --> Meta
AppApi --> OPDS
AppApi --> Hardcover
AppApi --> TTS
AppApi --> IAP
Web -.direct.-> Dict
Desktop -.direct.-> Dict
Mobile -.direct.-> Dict
Web -.direct.-> Readwise
Desktop -.direct.-> Readwise
```
The `Backend` box is **the same code on all clients**. In the web target it is
deployed as a Cloudflare Worker (via `@opennextjs/cloudflare` and
`wrangler.toml`). In the Tauri targets the same routes are served by a Next.js
runtime, but most clients hit the production deployment over HTTPS.
## 2. Process boundaries
There are three runtimes in play:
```mermaid
flowchart TB
subgraph Browser["Web runtime (browser / Tauri webview)"]
UI["React UI<br/>(src/app, src/components, src/hooks, src/store)"]
Domain["Shared domain layer<br/>(src/services, src/utils, src/libs)"]
Foliate["foliate-js<br/>(packages/foliate-js)"]
SW["Service worker (sw.ts)"]
TursoWasm["Turso WASM<br/>(replica DB in browser)"]
end
subgraph Native["Tauri native host (Rust)"]
TauriCore["src-tauri/src/lib.rs<br/>(commands, dir_scanner, transfer_file, clip_url, discord_rpc)"]
Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, deep-link, opener, updater,<br/>native-bridge, native-tts, turso, webview-upgrade)"]
end
subgraph Server["Next.js server (Worker / Node)"]
Routes["App Router + Pages Router routes"]
Mw["middleware.ts<br/>(CORS + COOP/COEP)"]
RuntimeRoute["app/runtime-config.js<br/>(server-rendered config script)"]
end
UI --> Domain
Domain --> Foliate
UI --> SW
Domain --> TursoWasm
Domain -- "@tauri-apps/api invoke()" --> TauriCore
TauriCore --> Plugins
Domain -- "fetch(/api/...)" --> Routes
Browser -- "<script src=/runtime-config.js>" --> RuntimeRoute
Routes --> Mw
```
Three things are worth calling out:
The same `src/services/*` code runs on both sides of the `invoke()` boundary on
desktop/mobile and on both sides of the `fetch()` boundary on web. Which
implementation is picked is decided at runtime by `src/services/environment.ts`
plus the platform-specific `*AppService.ts` (`webAppService`, `nativeAppService`,
`nodeAppService`) — see section 4.
`middleware.ts` does two things and only two things: CORS for `/api/*`, and
`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy:
require-corp` on every document. The COOP/COEP pair is required so that the
browser exposes `SharedArrayBuffer`, which the Turso WASM thread pool needs in
order to run the in-browser replica database; without those headers
`initThreadPool` hangs.
`/runtime-config.js` is a server route that emits
`window.__READEST_RUNTIME_CONFIG = {...}` as a JavaScript file. It is loaded as
a `<script>` tag from `app/layout.tsx` and `pages/_document.tsx`. This is what
lets a single Docker image be rebranded with a different Supabase project, S3
endpoint, or quota at deploy time without rebuilding — see commit
`9ad43aa8` and the `docker/` directory.
## 3. Frontend architecture
The frontend is a Next.js 16 + React 19 app. It uses both routers:
| Concern | Lives in | Why |
|---|---|---|
| Library, reader, auth, OPDS, send, user pages | `src/app/*` (App Router) | Standard for new pages; supports server components and the runtime-config route. |
| Reader entry by ID list `/reader/[ids]` | `src/pages/reader/[ids].tsx` (Pages Router) | Historical entrypoint; coexists with the App Router reader. |
| Cross-origin isolation document shell | `src/pages/_document.tsx` | Pages Router still owns `<Document>` for COOP/COEP and `runtime-config.js`. |
| HTTP API endpoints | both `src/app/api/*` and `src/pages/api/*` | Mix of new App Router routes and legacy Pages Router routes. |
### 3.1 UI module map
```mermaid
flowchart TB
Layout["app/layout.tsx<br/>(root shell, runtime-config script, Providers)"]
Library["app/library<br/>(grid, import, sort, OPDS shelf)"]
Reader["app/reader<br/>(views + tooling)"]
Auth["app/auth<br/>(Supabase auth UI)"]
Send["app/send<br/>(send-to-Readest inbox)"]
User["app/user<br/>(account, subscription, settings)"]
Updater["app/updater"]
Offline["app/offline"]
OPDS["app/opds<br/>(catalog browser)"]
Share["app/s, app/o<br/>(share landing pages)"]
Layout --> Library
Layout --> Reader
Layout --> Auth
Layout --> Send
Layout --> User
Layout --> OPDS
subgraph ReaderInternals["app/reader internals"]
ReaderPage["page.tsx"]
ReaderComps["components/*<br/>(BookView, Sidebar, Notebook,<br/>Annotator, FootnotePopup, Translator,<br/>RSVP overlay, AIChat, ParallelView, ...)"]
ReaderHooks["hooks/*<br/>(useFoliateEvents, useScrollHandler,<br/>useProgressSync, useAnnotations, ...)"]
ReaderUtils["utils/*"]
end
Reader --> ReaderInternals
subgraph Shared["Shared UI primitives"]
Components["components/*<br/>(Button, Dialog, Menu, Toast,<br/>BookCover, AppLockScreen, ...)"]
Settings["components/settings/*<br/>(Layout/Font/Color/Custom panels)"]
Assistant["components/assistant/*<br/>(AI chat composer)"]
CmdPalette["components/command-palette"]
end
```
The biggest UI cluster by far is `app/reader`: roughly 80 components and 30
hooks coordinating Foliate-based rendering, annotations, footnote popovers, the
notebook side panel, parallel view, RSVP, AI chat, translator overlays,
search, TTS, and the settings panels under `components/settings`.
### 3.2 State (Zustand)
Frontend state is split across single-purpose Zustand stores in `src/store`.
Each store maps to a clearly delimited concern, which keeps the reader from
collapsing into one mega-context:
```
libraryStore -> books, folders, selection, sort
bookDataStore -> per-book data (TOC, annotations, locations)
readerStore -> active views, layout, ribbon state
parallelViewStore -> two-pane reading
notebookStore -> notebook side panel
settingsStore -> user/app settings
themeStore -> light/dark/atmosphere
sidebarStore -> sidebar visibility/width
trafficLightStore -> macOS traffic-light positioning
appLockStore -> app PIN lock
deviceStore -> device profile
transferStore -> in-flight uploads/downloads
aiChatStore -> AI chat sessions
proofreadStore -> proofread side flow
atmosphereStore -> ambient overlay
customDictionaryStore / customFontStore /
customTextureStore / customOPDSStore -> user-imported assets
```
### 3.3 In-browser book engine
EPUB / MOBI / KF8 / FB2 / CBZ / TXT / PDF parsing and rendering is **not**
hand-rolled in this repo. The reader sits on top of `packages/foliate-js`, a
forked copy of the Foliate JS engine. Readest's reader code in `app/reader` and
the adapters under `src/services/annotation`, `src/services/nav`,
`src/services/transformers`, and `src/services/rsvp` wrap that engine and add
features (annotations sync, navigation, content transforms, vertical/Warichu
support, classic mode overlays, etc.).
PDF rendering goes through `pdfjs-dist`, which is copied into
`public/vendor/pdfjs` at build time (`pnpm setup-pdfjs`). Chinese conversion
uses `simplecc-wasm` (`public/vendor/simplecc`), and Chinese segmentation uses
`jieba-wasm` (`public/vendor/jieba`).
### 3.4 Service worker and offline
`src/sw.ts` is a Serwist service worker that gives the web build offline
support: cached static assets, cached API responses for read-only data, and an
offline route at `/offline`.
## 4. The platform abstraction (`AppService`)
The single most important abstraction in the codebase is
`src/services/appService.ts`. Every piece of code that touches "the platform"
(file system, native dialogs, shell open, native TTS, IAP, dir scanning,
deep links, etc.) goes through an `AppService` interface. There are three
implementations:
```mermaid
flowchart LR
Caller["UI code, hooks, services"]
AppSvc["AppService interface<br/>(services/appService.ts)"]
Native["nativeAppService.ts<br/>(Tauri desktop + mobile)"]
Web["webAppService.ts<br/>(browser / web build)"]
Node["nodeAppService.ts<br/>(Node tooling, tests, CLI)"]
Caller --> AppSvc
AppSvc --> Native
AppSvc --> Web
AppSvc --> Node
Native -- "@tauri-apps/api invoke()" --> Rust["src-tauri Rust commands"]
Native --> Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, native-bridge, native-tts, turso)"]
Web --> Browser["browser APIs (File, IndexedDB, fetch)"]
Web --> RemoteAPI["fetch() to /api/*"]
Node --> Fs["node:fs, node:path"]
```
`environment.ts` decides at runtime which implementation to mount, based on the
build target (`NEXT_PUBLIC_APP_PLATFORM`) and runtime detection (`window`,
Tauri injection). Most callers in the codebase do
`const appService = useEnv().appService` and never know which one they got.
The same pattern repeats for the database layer in `src/services/database`:
`webDatabaseService` (browser via Turso WASM), `nativeDatabaseService` (Tauri
via the `tauri-plugin-turso` plugin), and `nodeDatabaseService` (Node, used by
tests). All three share `migrate.ts` and `migrations/*`.
This is why most domain code in `src/services` looks platform-agnostic — the
platform difference has been pushed to a small number of seams.
## 5. Backend (Next.js routes)
There are two route trees because of historical mix between App Router and
Pages Router. The split is pragmatic, not load-bearing: new routes go to
`src/app/api`, legacy/sync/storage live in `src/pages/api`.
### 5.1 Pages Router endpoints (`src/pages/api`)
These are the long-standing server endpoints around sync, storage, and email:
```
sync.ts -> KOReader-compatible sync client (`KOSyncClient`)
kosync.ts -> KOSync legacy bridge
sync/replicas.ts -> replica sync upload/download (encrypted blobs)
sync/replica-keys.ts -> replica key bootstrap
storage/upload.ts -> presigned upload to S3/R2 for book bytes
storage/download.ts -> presigned download
storage/list.ts -> list user's objects
storage/delete.ts -> delete a single object
storage/purge.ts -> bulk wipe (account deletion path)
storage/stats.ts -> per-user usage/quotas
send/inbox.ts -> "Send to Readest" inbox listing
send/inbox/* -> inbox item operations
send/address.ts -> per-user inbox address resolver
send/fetch-url.ts -> server-side URL fetcher for "send a link"
send/senders.ts -> sender allowlist
deepl/translate.ts -> DeepL translation proxy (hides API key)
user/delete.ts -> account deletion
```
The storage layer talks to S3-compatible storage through `src/utils/s3.ts`,
which honors a `S3_PUBLIC_ENDPOINT` distinct from the internal endpoint so
docker-compose deployments can route browsers through one origin and the
server through another.
### 5.2 App Router endpoints (`src/app/api`)
Newer endpoints, grouped by domain:
```
ai/chat -> streaming AI chat (Vercel AI SDK)
ai/embed -> embeddings for in-book RAG
metadata/search -> metadata lookup (Google Books / Open Library)
opds/proxy -> CORS-friendly OPDS proxy
tts/edge -> Edge TTS streaming
hardcover/graphql -> Hardcover GraphQL relay
stripe/checkout -> create checkout session
stripe/portal -> billing portal redirect
stripe/plans -> plan listing
stripe/check -> subscription state
stripe/webhook -> Stripe webhook handler
google/iap-verify -> Google Play IAP verification
apple/iap-verify -> App Store IAP verification
share/* -> share-link landing + read-only render
```
### 5.3 Workers
`apps/readest-app/workers/send-email` is a separate Cloudflare Worker
(deployed independently from the main app) responsible for the "Send to Readest
by email" path. It receives mail, normalizes attachments, and drops items into
the user's inbox so that the in-app `Send` page can pick them up via the
`/api/send/inbox` endpoints.
### 5.4 Runtime config
`src/app/runtime-config.js/route.ts` is a server route that builds a small JSON
object — `supabaseUrl`, `supabaseAnonKey`, `apiBaseUrl`, `objectStorageType`,
`storageFixedQuota`, `translationFixedQuota` — from `process.env` at request
time and serializes it as a JS payload. The client reads it through
`getRuntimeConfig()` in `src/services/runtimeConfig.ts` (browser) or
`getServerRuntimeConfig()` (server). This is the mechanism that makes the same
prebuilt Docker image rebrandable per deployment.
## 6. Cross-cutting subsystems
These don't live in one file or one route; they span the frontend, the backend,
and (sometimes) the native shell.
### 6.1 Sync
Two sync paths coexist:
The first is **legacy KOReader-compatible sync** for reading progress,
implemented by `src/services/sync/KOSyncClient.ts` against `pages/api/sync.ts`
and `pages/api/kosync.ts`. It exists for compatibility with KOSync-style
clients.
The second is **replica sync**, the modern path. It encrypts each replica
locally with a passphrase-derived key
(`replicaCryptoMiddleware.ts`, `passphraseGate.ts`), publishes deltas to
`pages/api/sync/replicas.ts`, pulls peer updates, and applies them through
category adapters in `src/services/sync/adapters/*` (annotations, settings,
dictionaries, fonts, textures, OPDS catalogs). The orchestrator is
`replicaSyncManager.ts`. A cursor store (`replicaCursorStore.ts`) tracks "where
I last pulled to" per category so syncs are incremental.
### 6.2 Cloud library
Distinct from replica sync. The cloud library handles **book bytes** (not
metadata):
- import flow: `src/services/ingestService.ts` decides whether a book is
imported as a hash copy under `Books/<hash>/` or kept *in place* at the
user's chosen path (the "in-place" mode added in commit `dd107277`).
- upload: `cloudService.uploadBook` uses the storage layer to push bytes to S3
through `pages/api/storage/upload.ts`.
- download: peers fetch via `pages/api/storage/download.ts`, materializing the
book into `Books/<hash>/` regardless of whether the original device kept it
in-place.
- delete: symmetric local/cloud/both semantics in `cloudService.deleteBook`.
### 6.3 AI / RAG
`src/services/ai` provides the chat and embedding abstraction with provider
adapters, prompt assembly, chunking, retry, and a local AI store. UI lives in
`components/assistant` and the reader-side `app/reader/components/AIChat*`. The
HTTP entrypoints are `src/app/api/ai/chat` (streaming) and
`src/app/api/ai/embed`. The reader can do book-scoped RAG by embedding chapters
locally and querying the embeddings store.
### 6.4 Translation
`src/services/translators` has provider adapters for DeepL, Google, Azure, and
Yandex, plus a preprocess + cache + polish pipeline. DeepL goes through a
server proxy (`pages/api/deepl/translate.ts`) to keep the API key server-side;
the others can hit the providers directly from the client.
### 6.5 TTS
Three TTS backends behind one interface (`src/services/tts`):
- `WebSpeechClient` for browsers,
- `NativeTTSClient` for Tauri via `tauri-plugin-native-tts`,
- `EdgeTTSClient` going through `src/app/api/tts/edge` for streaming Microsoft
Edge voices.
### 6.6 Dictionaries
`src/services/dictionaries` parses StarDict and SLOB packs locally
(`readers/`), and integrates online sources (Wikipedia, Wiktionary,
provider-specific). Lookup goes through a candidate generator + dedup so
clicking a word finds all installed dictionaries and online sources in one
roundtrip.
### 6.7 OPDS / Calibre
`src/services/opds` parses feeds, supports auto-download, and tracks
subscription state. Cross-origin feeds are tunneled through
`src/app/api/opds/proxy`. The library UI surfaces OPDS shelves alongside local
books.
### 6.8 Third-party reading services
Hardcover (`src/services/hardcover` + `src/app/api/hardcover/graphql`) and
Readwise (`src/services/readwise`) integrations let users export reading
progress and highlights.
### 6.9 Annotations
`src/services/annotation` defines the canonical annotation model and provides
adapters: a Foliate adapter (the default in-app representation) and an MR
import/export adapter for moving annotations to and from MoonReader.
### 6.10 RSVP and content transforms
`src/services/rsvp` is the rapid-serial-visual-presentation reading mode.
`src/services/transformers` contains pure functions for language detection,
punctuation normalization, whitespace collapsing, proofread suggestions,
sanitization, footnote rewriting, style injection, traditional/simplified
Chinese conversion (via `simplecc-wasm`), and Warichu (Japanese ruby/rubi)
layout. These are reused by the reader, by RSVP, and by the
"Send to Readest" article-to-EPUB conversion.
### 6.11 Send to Readest
End-to-end pipeline:
1. The browser extension (`apps/readest-app/extension/send-to-readest`) or the
email-to-inbox path (`workers/send-email`) submits a URL or article HTML.
2. `src/services/send/conversion/*` sanitizes the content and converts it to
EPUB (sanitization, TOC building, asset bundling, worker protocol).
3. The result lands in the user's inbox served by `pages/api/send/inbox*`.
4. The `app/send` page or the in-app inbox drainer
(`src/services/send/inboxDrainer.ts`) imports it into the library through
the standard ingest service.
## 7. Native shell (`src-tauri`)
The Tauri host is shared by desktop and mobile. The Rust side (`src-tauri/src`)
is small and focused:
```
lib.rs -> command registration, scope grants, deep links, builder
main.rs -> entrypoint
clip_url.rs -> clipboard URL extraction
dir_scanner.rs -> recursive directory scan (used by library import)
transfer_file.rs -> chunked upload/download for big files
discord_rpc.rs -> Discord Rich Presence (desktop only)
android/, macos/,
windows/ -> per-platform glue
```
Everything else is delegated to **Tauri plugins**, mostly bundled in
`packages/tauri-plugins/plugins`:
- standard plugins: `fs`, `dialog`, `http`, `opener`, `os`, `process`, `shell`,
`cli`, `deep-link`, `haptics`, `log`, `updater`, `websocket`, `oauth`,
`persisted-scope`, `device-info`, `sharekit`
- in-tree custom plugins:
- `tauri-plugin-native-bridge` — Android-side bridges (directory picker
callback, open external URL, etc.)
- `tauri-plugin-native-tts` — native text-to-speech
- `tauri-plugin-turso` — embedded Turso/libSQL database for the native
targets, mirrored by the WASM build used in the browser
- `tauri-plugin-webview-upgrade` — webview update flow on platforms where
that matters
A subtle but important detail in `lib.rs`: `allow_paths_in_scopes` is the
frontend-callable shim that extends both `fs_scope` and `asset_protocol_scope`
**only for paths the Tauri dialog plugin (or persisted-scope on restart)
already granted**. Without that gate, any frontend code path — including a
hypothetical XSS through book content, OPDS HTML, or a compromised dependency —
could grant itself read access to the user's home directory through the asset
protocol. The gate constrains the command to user-picked paths only.
## 8. Build and deploy
```mermaid
flowchart LR
Source["apps/readest-app (single source)"]
subgraph BuildTargets
BWeb["next build<br/>+ @opennextjs/cloudflare<br/>(.env.web)"]
BTauriDesk["next build → tauri build<br/>(.env.tauri)"]
BTauriMob["next build → tauri android/ios build"]
end
subgraph DeployTargets
DCloudflare["Cloudflare Workers<br/>(web.readest.com)"]
DDocker["Docker image<br/>(ghcr.io/readest/readest)"]
DDesktop["dmg / nsis / appimage"]
DMobile["aab / ipa"]
DExt["browser extension package"]
end
Source --> BWeb
Source --> BTauriDesk
Source --> BTauriMob
BWeb --> DCloudflare
BWeb --> DDocker
BTauriDesk --> DDesktop
BTauriMob --> DMobile
Source --> DExt
```
The web target has two delivery modes: a Cloudflare Worker via OpenNext
(`pnpm deploy`) and a self-hostable Docker image built and published from
`.github/workflows/docker-image.yml` to GHCR and Docker Hub. The Docker image
uses `docker/compose.yaml` (pull) plus `docker/compose.build.yaml` (build) and
relies on the runtime-config mechanism described in section 5.4 so a single
prebuilt image can be parameterized with `.env`.
Tauri builds use `dotenv` to switch env files (`.env.tauri`,
`.env.tauri.local`, `.env.apple-*.local`, `.env.ios-*.local`,
`.env.google-play.local`) for code-signing and store-specific configuration.
Mobile and desktop produce installable bundles (dmg, nsis, appimage, aab, ipa).
## 9. Quick rule of thumb
When trying to place a piece of behavior, ask in this order:
Does it talk to a remote service or write to durable shared storage? Then it
ends up in `src/pages/api` or `src/app/api`, possibly fronted by a service
under `src/services`. Does it touch the user's filesystem, native dialogs, the
shell, or system TTS? Then it goes through `appService` and lands in
`nativeAppService` (Tauri commands in `src-tauri/src/lib.rs`) or
`webAppService` (browser equivalent). Does it manipulate book content, render
the reader, or maintain UI state? Then it lives under `src/app/reader`,
`src/components`, `src/hooks`, `src/store`, or one of the reader-side service
folders (`annotation`, `nav`, `rsvp`, `transformers`, `dictionaries`,
`translators`, `tts`). Is it a sync or cloud-library concern? `src/services/sync`
plus the matching API route, or `src/services/cloudService.ts` plus
`pages/api/storage/*`.
If you can answer "which runtime owns this" in one sentence, you've placed the
file correctly. If you can't, it's probably shared and belongs under
`src/services`, `src/utils`, `src/libs`, or `src/types`.
-57
View File
@@ -1,57 +0,0 @@
# Book Config JSON
Each imported book may have a per-book config file at:
```text
<bookHash>/config.json
```
The file is written under the `Books` storage root and uses the same camelCase
keys as the TypeScript `BookConfig` type in `src/types/book.ts`.
## Version
`schemaVersion` identifies the raw `config.json` schema written by Readest.
Current version:
```json
{
"schemaVersion": 1
}
```
Configs written before this field existed are treated as legacy configs and are
loaded as the current version. New writes must include `schemaVersion`.
`schemaVersion` is only for the raw disk JSON file. The cloud sync
`book_configs` table is a normalized sync projection and does not mirror this
field.
## Stable Fields
Version 1 documents these fields as the supported integration surface:
- `schemaVersion`: raw config schema version.
- `bookHash`, `metaHash`: book identity when present.
- `progress`: current page tuple, `[current, total]`.
- `location`: current reading location as CFI.
- `xpointer`: current reading location as XPointer for KOReader interoperability.
- `booknotes`: bookmarks, annotations, and excerpts.
- `rsvpPosition`: RSVP reading position.
- `updatedAt`: last config update timestamp in milliseconds.
`viewSettings` and `searchConfig` are persisted app state. They are partial
overrides and are merged with defaults when Readest loads the config.
## Notes and XPointer Fields
`BookConfig.xpointer` is the current reading location. It was not renamed by
KOReader annotation sync work.
For notes in `booknotes`, Readest stores note ranges with:
- `xpointer0`: start XPointer.
- `xpointer1`: end XPointer, when available.
This distinction matters for integrations reading raw config files: progress
uses `xpointer`, while note ranges use `xpointer0` and `xpointer1`.
-341
View File
@@ -1,341 +0,0 @@
# Readest App Code Layout
This note summarizes the runtime boundaries inside `apps/readest-app`, with two goals:
- explain which directories are server-side, client-side, or mixed
- explain the directory-level role of `apps/readest-app/src/services`
## First: `src-tauri`
`apps/readest-app/src-tauri` is the Tauri native shell layer for all Tauri targets, not just desktop.
- Desktop: Windows, macOS, Linux
- Mobile: Android, iOS
That is visible in `apps/readest-app/src-tauri/tauri.conf.json`, which contains both `bundle.android` and `bundle.iOS` configuration, plus mobile deep-link settings.
So the rough split is:
- `apps/readest-app/src`: the Next.js/React app
- `apps/readest-app/src-tauri`: the native host layer for Tauri desktop and mobile builds
## Directory classification inside `apps/readest-app`
### Mostly server-side directories
- `apps/readest-app/src/app/api`
Next.js App Router server endpoints (`route.ts`). These run on the server / edge runtime, not in the browser.
- `apps/readest-app/src/pages/api`
Next.js Pages Router API endpoints. This is where the classic server handlers live, including sync, storage, send, DeepL, and user endpoints.
- `apps/readest-app/src/app/runtime-config.js`
A server route that emits runtime JavaScript config for the client.
- `apps/readest-app/workers`
Worker-side backend code outside the normal page UI tree. For example, `workers/send-email` is operational backend code.
### Mostly client-side directories
- `apps/readest-app/src/components`
Reusable React UI components.
- `apps/readest-app/src/context`
React context providers and app state wiring.
- `apps/readest-app/src/hooks`
Client-side React hooks.
- `apps/readest-app/src/store`
Frontend state stores.
- `apps/readest-app/src/styles`
Styling, theme assets, and UI presentation helpers.
- `apps/readest-app/src/data`
Static or bundled app data.
- `apps/readest-app/src/i18n`
Internationalization resources and setup.
- `apps/readest-app/src/workers`
Browser worker code used by the frontend.
- `apps/readest-app/public`
Static assets served to the frontend.
- `apps/readest-app/extension`
Browser-extension-specific client code.
- `apps/readest-app/extensions`
Platform integration extensions such as Windows thumbnail support.
### Mixed or shared directories
- `apps/readest-app/src/app`
Mostly frontend routes and UI, but not purely client-side. In Next App Router, `page.tsx`, `layout.tsx`, and related files can mix server rendering and client components. The exception is `src/app/api`, which is server-only.
- `apps/readest-app/src/pages`
Mixed. `src/pages/api` is server-only; `src/pages/reader/[ids].tsx` is frontend page code; `_document.tsx` is server-side document wiring.
- `apps/readest-app/src/services`
Shared domain/service layer. Most of this is not “backend-only”; it contains platform adapters, client logic, network clients, sync logic, and some code that is reused by server routes.
- `apps/readest-app/src/utils`
Shared helpers used by both frontend code and server handlers.
- `apps/readest-app/src/libs`
Shared library code. Some of it is server-oriented, some client-oriented, some neutral.
- `apps/readest-app/src/helpers`
General helper code, usually shared.
- `apps/readest-app/src/types`
Shared type definitions.
- `apps/readest-app/src/__tests__`
Test code covering both client and server behavior.
- `apps/readest-app/e2e`
End-to-end test suite.
- `apps/readest-app/scripts`
Build, release, and maintenance scripts.
- `apps/readest-app/docs`
App-specific documentation.
## `src/app` and `src/pages` at directory level
### `src/app`
- `src/app/api`: server-side HTTP endpoints
- `src/app/auth`: auth pages and auth-related UI/helpers
- `src/app/library`: library UI
- `src/app/o`: frontend route
- `src/app/offline`: frontend offline page
- `src/app/opds`: OPDS browsing UI
- `src/app/reader`: reader UI
- `src/app/runtime-config.js`: server-generated runtime config endpoint
- `src/app/s`: share landing UI
- `src/app/send`: send/import UI
- `src/app/updater`: updater UI
- `src/app/user`: account/subscription/settings UI
So `src/app` is mostly application UI, with one explicitly server-only subtree: `src/app/api`, plus the runtime-config route.
### `src/pages`
- `src/pages/api`: server-side API routes
- `src/pages/reader`: frontend page route(s)
- `src/pages/_app.tsx`: application wrapper for Pages Router
- `src/pages/_document.tsx`: server-side document shell
So `src/pages` is mixed, not purely client-side.
## `src/services` breakdown
The most important point is this:
- `src/services` is mostly a shared application/service layer
- it is not the same thing as “backend code”
- actual HTTP server entrypoints are mainly under `src/pages/api` and `src/app/api`
### Top-level files in `src/services`
- `appService.ts`
Base application service abstraction.
- `nativeAppService.ts`
Native/Tauri-facing app service implementation.
- `nodeAppService.ts`
Node-capable service implementation.
- `webAppService.ts`
Web/browser-oriented service implementation.
- `bookService.ts`
Book-level operations such as covers, metadata shaping, and book-related domain logic.
- `libraryService.ts`
Library management logic.
- `settingsService.ts`
Reading and persisting settings.
- `backupService.ts`
Backup/import-export related logic.
- `cloudService.ts`
Cloud-related app behavior.
- `fontService.ts`
Custom font handling.
- `imageService.ts`
Image-related helper logic.
- `ingestService.ts`
Import / ingest pipeline for incoming content.
- `persistence.ts`
Shared persistence utilities.
- `transformService.ts`
Content transformation entrypoints.
- `commandRegistry.ts`
Command registration / dispatch.
- `transferManager.ts` and `transferMessages.ts`
Transfer pipeline coordination.
- `environment.ts` and `runtimeConfig.ts`
Runtime environment detection and injected runtime configuration.
- `constants.ts` and `errors.ts`
Shared constants and error types.
These top-level files are mostly shared client/application-layer code, with some runtime branching for web, node, and Tauri.
### `src/services/database`
Platform-specific database access and migrations.
- `webDatabaseService.ts`: browser/web DB implementation
- `nodeDatabaseService.ts`: Node-side DB implementation
- `nativeDatabaseService.ts`: native/Tauri DB implementation
- `migrate.ts` and `migrations/`: schema and migration logic
This is shared infrastructure code, not an HTTP backend directory.
### `src/services/sync`
Sync clients and replica-sync orchestration.
- legacy/remote sync client code such as `KOSyncClient.ts`
- replica sync flow: bootstrap, publish, pull, apply, persistence, cursor storage, encryption, and passphrase handling
- adapter subdirectory for sync categories such as dictionary, font, texture, OPDS catalog, and settings
This is mostly client-side sync orchestration talking to backend endpoints like `src/pages/api/sync.ts` and `src/pages/api/sync/replicas.ts`.
### `src/services/send`
“Send to Readest” and content conversion logic.
- `sendAddress.ts`, `devicePrefs.ts`, `inboxDrainer.ts`
- `conversion/`: article/page-to-EPUB conversion pipeline, sanitization, TOC building, asset bundling, and worker protocol
This is mostly application logic used by frontend flows and server endpoints together.
### `src/services/metadata`
Book metadata lookup services.
- provider implementations like Google Books and Open Library
- shared metadata types and orchestration service
This is shared integration logic. Actual HTTP exposure happens via route handlers such as `src/app/api/metadata/search`.
### `src/services/dictionaries`
Dictionary import, parsing, lookup, and provider registry.
- readers/parsers for StarDict, SLOB, and related formats
- provider adapters for dictionary/web/wikipedia/wiktionary sources
- dictionary service, deduplication, content ID, and lookup candidate generation
This is primarily client/application functionality.
### `src/services/annotation`
Annotation models and provider adapters.
- annotation types and normalization
- provider adapters such as Foliate and MR export/import
Mostly shared reader-side logic.
### `src/services/nav`
Navigation, fragments, grouping, locations, and lookup utilities for books.
Mostly client-side reader logic.
### `src/services/opds`
OPDS feed handling and subscription state.
- feed parsing/checking
- auto-download support
- stream and subscription helpers
Mostly frontend/domain logic, sometimes paired with server proxy routes.
### `src/services/translators`
Translation provider integration.
- provider adapters for DeepL, Google, Azure, Yandex
- preprocessing, cache, polish, and translator utilities
Mixed integration code. Some providers are used via server APIs to avoid exposing secrets.
### `src/services/tts`
Text-to-speech abstraction and implementations.
- `WebSpeechClient.ts`: browser TTS
- `NativeTTSClient.ts`: native/Tauri TTS
- `EdgeTTSClient.ts`: remote/provider-backed TTS
- controller/data/types/utilities
Mixed runtime code, mostly used by the reader frontend.
### `src/services/ai`
AI chat/embedding/RAG related abstractions.
- adapters and providers
- prompts, chunking, retry logic, logging
- local AI store and RAG service
Mixed integration code. The services are shared, while actual HTTP endpoints live under `src/app/api/ai`.
### `src/services/hardcover` and `src/services/readwise`
Third-party reading service integrations.
- Hardcover sync client and mapping store
- Readwise client integration
Mostly client/application integration code.
### `src/services/rsvp`
RSVP reader mode logic.
- controller, persistence, utilities, and types
Client-side reading feature code.
### `src/services/transformers`
Text/content transformation modules.
- language, punctuation, whitespace, proofread, sanitization, footnote, style, simplecc, warichu
Shared pure logic, usually frontend-facing but not tied to a single runtime.
## Practical mental model
If you want a fast rule of thumb for this repo, use this:
- HTTP backend entrypoints: `src/pages/api`, `src/app/api`, `workers`
- frontend UI/routes: `src/app` except `api`, plus `src/components`, `src/hooks`, `src/store`
- shared app/domain logic: `src/services`, `src/utils`, `src/libs`, `src/types`
- native host layer for desktop + Android + iOS: `src-tauri`
That model matches the codebase much better than “everything under `src` is client code.”
-72
View File
@@ -1,72 +0,0 @@
## i18n Guide
Readest uses a **key-as-content** approach — English strings are the translation keys. The English locale (`en/translation.json`) is empty because keys serve as content. Other locales contain actual translations.
### In React Components
```tsx
import { useTranslation } from '@/hooks/useTranslation';
const _ = useTranslation();
_('Progress synced');
```
### In Non-React Modules
Two-step process:
**1. Declaration** — Use `stubTranslation` to mark strings for scanner extraction (returns key as-is, does NOT translate):
```ts
import { stubTranslation as _ } from '@/utils/misc';
// These calls only register keys for extraction
_('Reveal in Finder');
_('Reveal in Explorer');
```
**2. Usage** — In the React component that consumes the value, apply the real `_()` from `useTranslation`:
```tsx
const _ = useTranslation();
const label = _(getRevealLabel()); // translates at runtime
```
### Extraction & Translation
```bash
pnpm i18n:extract # Scans codebase, adds new keys with __STRING_NOT_TRANSLATED__
```
- Translation files: `public/locales/<locale>/translation.json`
- Only `_('KEY')` and `_('KEY', options)` patterns are recognized by i18next-scanner
### Adding a New Translation Language
The supported language set has a single ground truth: [`i18n-langs.json`](../i18n-langs.json). Both the i18next runtime (`src/i18n/i18n.ts`) and the extractor (`i18next-scanner.config.cjs`) read from it, so adding a locale is a two-file change plus a translation pass.
1. **Add the locale code** to [`i18n-langs.json`](../i18n-langs.json). Use the exact code i18next will emit (e.g. `hu`, `zh-CN`). Do not add `en` — it's the source language and lives outside this list.
2. **Add a display label** to `TRANSLATED_LANGS` in [`src/services/constants.ts`](../src/services/constants.ts). The key is the locale code, the value is the language's native name (e.g. `hu: 'Magyar'`). This is what users see in the language picker.
3. **Generate the translation file**:
```bash
pnpm i18n:extract
```
This creates `public/locales/<code>/translation.json` with every key set to `__STRING_NOT_TRANSLATED__`.
4. **Translate** every `__STRING_NOT_TRANSLATED__` placeholder in the new file. The `/i18n` skill automates this; the singular `en/translation.json` only holds plural variants and proper nouns, so use the JSON keys themselves as the English source.
5. **Verify** with `grep -r "__STRING_NOT_TRANSLATED__" public/locales/<code>/` — the result should be empty.
6. **Translate the KOReader companion plugin** (`apps/readest.koplugin`). It pulls the locale set from the same `i18n-langs.json` via the scanner config, but the catalog format is gettext `.po`, not JSON. Steps:
- Add a `LANG_META` entry (label + Plural-Forms) for the new code in [`apps/readest.koplugin/scripts/extract-i18n.js`](../../readest.koplugin/scripts/extract-i18n.js). Without it the extractor prints `<code> skipped (no metadata in extract-i18n.js)` and the catalog is never created.
- Trigger the `/i18n-koplugin` skill to run extraction and fill every empty `msgstr ""` in `apps/readest.koplugin/locales/<code>/translation.po`.
### Rules
- `stubTranslation` is for extraction only — always apply `_()` from `useTranslation` in the component for runtime translation.
- Fallback: when no translation exists, the English key itself is displayed.
- Error messages: register keys with `stubTranslation` in utility modules (e.g. `src/services/errors.ts`), return the English key from helpers, wrap with `_()` in the component.
-54
View File
@@ -1,54 +0,0 @@
## Safe Area Insets
The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured.
### Key Concepts
- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components.
- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`.
- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`.
- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices).
### Top Inset Rules
For UI elements anchored to the **top** of the screen (headers, close buttons, overlays):
```tsx
// When system UI is visible, use the larger of gridInsets.top and statusBarHeight
// When system UI is hidden, use gridInsets.top alone
style={{
marginTop: systemUIVisible
? `${Math.max(gridInsets.top, statusBarHeight)}px`
: `${gridInsets.top}px`,
}}
```
For containers that need safe area padding at the top:
```tsx
style={{
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
}}
```
For top-anchored slide-in panels (sidebar, notebook), use `getPanelTopInset()` from `src/utils/insets.ts`. It clears the status bar on tablet/desktop and full-height mobile sheets, but stays flush for a partial-height mobile bottom sheet (which doesn't reach the top of the screen). Gating only on `isFullHeightInMobile` is wrong — a non-mobile panel is also top-anchored and would let the status bar obscure its toolbar.
### Bottom Inset Rules
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
```tsx
style={{
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
}}
```
### Passing `gridInsets`
When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly:
```tsx
<ImageViewer gridInsets={gridInsets} ... />
<TableViewer gridInsets={gridInsets} ... />
<ZoomControls gridInsets={gridInsets} ... />
```
@@ -1,613 +0,0 @@
# Line-Aware Reading Ruler Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the reading ruler snap to the next group of *actual* rendered text lines on each tap/page-change so lines stay centered in the band, eliminating the drift caused by the current arithmetic step.
**Architecture:** Two new pure functions in `src/app/reader/utils/readingRuler.ts` derive line boxes from `progress.range.getClientRects()` and compute a snapped band center. `ReadingRuler.tsx` caches the line boxes per page and calls the snap function from both the tap handler and the page-change auto-move, falling back to the existing arithmetic step when line geometry is unavailable (scrolled mode, fixed-layout, missing range).
**Tech Stack:** TypeScript, React, Vitest, foliate-js paginator (`progress.range`).
**Design spec:** `docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md`
---
## File Structure
- `src/app/reader/utils/readingRuler.ts` (modify) — add `ReadingRulerLineBox` type, `READING_RULER_LINE_PADDING_PX`, `buildLineBoxes`, `snapReadingRulerToLines`. Pure, no DOM.
- `src/__tests__/utils/readingRuler.test.ts` (modify) — unit tests for the two new functions.
- `src/app/reader/components/ReadingRuler.tsx` (modify) — glue: padded band size, per-page line-box cache, snap in tap handler + page-change auto-move, fallbacks.
---
## Task 1: `ReadingRulerLineBox` type, padding constant, and `buildLineBoxes`
**Files:**
- Modify: `src/app/reader/utils/readingRuler.ts`
- Test: `src/__tests__/utils/readingRuler.test.ts`
`buildLineBoxes` converts per-fragment client rects (from `range.getClientRects()`) into sorted visual-line spans along the ruler axis, in container coordinates. It clusters fragments that belong to the same visual line (high overlap on the ruler axis) and maps coordinates exactly as the existing auto-move does:
- horizontal: span = `[rect.top - containerRect.top, rect.bottom - containerRect.top]`
- vertical-rl (`rtl=true`): span = `[containerRect.right - rect.right, containerRect.right - rect.left]`
- vertical-lr (`rtl=false`): span = `[rect.left - containerRect.left, rect.right - containerRect.left]`
- [ ] **Step 1: Write the failing tests**
Add these imports and tests to `src/__tests__/utils/readingRuler.test.ts`. Update the existing top import block to also import the new symbols:
```typescript
import {
buildLineBoxes,
calculateReadingRulerSize,
clampReadingRulerPosition,
FIXED_LAYOUT_READING_RULER_LINE_HEIGHT,
getReadingRulerMoveDirection,
READING_RULER_LINE_PADDING_PX,
snapReadingRulerToLines,
stepReadingRulerPosition,
} from '@/app/reader/utils/readingRuler';
```
Then add a new `describe` block at the end of the file:
```typescript
type RectLike = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
const rect = (top: number, left: number, height: number, width: number): RectLike => ({
top,
left,
bottom: top + height,
right: left + width,
height,
width,
});
const container = { top: 0, left: 0, right: 300, bottom: 400 };
describe('buildLineBoxes', () => {
it('clusters horizontal fragments into one box per visual line', () => {
const rects = [
rect(0, 10, 16, 50), // line 1, fragment A
rect(0, 60, 16, 40), // line 1, fragment B (same vertical band)
rect(20, 10, 16, 80), // line 2
];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 20, end: 36 },
]);
});
it('ignores zero-size rects', () => {
const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 40, end: 56 },
]);
});
it('returns sorted boxes even when rects are out of order', () => {
const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]);
});
it('maps vertical-rl columns as distance from the right edge', () => {
// container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40]
const rects = [rect(0, 260, 200, 16)];
expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]);
});
it('maps vertical-lr columns as distance from the left edge', () => {
const rects = [rect(0, 24, 200, 16)];
expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: FAIL — `buildLineBoxes`, `READING_RULER_LINE_PADDING_PX`, `snapReadingRulerToLines` are not exported (also a build/type error on the import).
- [ ] **Step 3: Implement the type, constant, and `buildLineBoxes`**
In `src/app/reader/utils/readingRuler.ts`, add below the existing `FIXED_LAYOUT_READING_RULER_LINE_HEIGHT` constant (line 3):
```typescript
// Extra band height (px) added so the centered lines clear the band edges.
export const READING_RULER_LINE_PADDING_PX = 6;
export interface ReadingRulerLineBox {
start: number;
end: number;
}
type RulerRect = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
type RulerContainerRect = { top: number; left: number; right: number };
/**
* Convert per-fragment client rects into sorted visual-line spans along the
* ruler axis, in container coordinates. Fragments that overlap by more than
* half of the smaller fragment on the ruler axis are treated as one line.
*/
export const buildLineBoxes = (
rects: RulerRect[],
isVertical: boolean,
rtl: boolean,
containerRect: RulerContainerRect,
): ReadingRulerLineBox[] => {
const spans: ReadingRulerLineBox[] = [];
for (const r of rects) {
if (!r || r.width <= 0 || r.height <= 0) continue;
let start: number;
let end: number;
if (isVertical) {
if (rtl) {
start = containerRect.right - r.right;
end = containerRect.right - r.left;
} else {
start = r.left - containerRect.left;
end = r.right - containerRect.left;
}
} else {
start = r.top - containerRect.top;
end = r.bottom - containerRect.top;
}
if (end < start) [start, end] = [end, start];
spans.push({ start, end });
}
spans.sort((a, b) => a.start - b.start || a.end - b.end);
const lines: ReadingRulerLineBox[] = [];
for (const span of spans) {
const current = lines[lines.length - 1];
if (current) {
const overlap = Math.min(current.end, span.end) - Math.max(current.start, span.start);
const minHeight = Math.min(current.end - current.start, span.end - span.start);
if (overlap > 0.5 * minHeight) {
current.start = Math.min(current.start, span.start);
current.end = Math.max(current.end, span.end);
continue;
}
}
lines.push({ start: span.start, end: span.end });
}
return lines;
};
```
- [ ] **Step 4: Run tests to verify the `buildLineBoxes` tests pass**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: the `buildLineBoxes` describe block PASSES. (The `snapReadingRulerToLines` import still makes the file fail to compile — that's fixed in Task 2. If the runner refuses to run due to the missing export, temporarily comment out the `snapReadingRulerToLines` import line to confirm, then restore it.)
- [ ] **Step 5: Commit**
```bash
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
git commit -m "feat(ruler): add buildLineBoxes for line-aware ruler geometry"
```
---
## Task 2: `snapReadingRulerToLines`
**Files:**
- Modify: `src/app/reader/utils/readingRuler.ts`
- Test: `src/__tests__/utils/readingRuler.test.ts`
Given the current band center, viewport dimension, padded band size, line count, direction, and the line boxes, return the next band center (px) centered on the next `lines`-line block — or `null` when there is no next group (caller falls back to a page flip).
- [ ] **Step 1: Write the failing tests**
Append to `src/__tests__/utils/readingRuler.test.ts`:
```typescript
describe('snapReadingRulerToLines', () => {
// 10 lines, each 16px tall, 20px apart, starting at 0.
const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({
start: s,
end: s + 16,
}));
it('returns null when there are no line boxes', () => {
expect(snapReadingRulerToLines(100, 400, 40, 2, 'forward', [])).toBeNull();
});
it('advances forward to the next block of N lines, centered on the block', () => {
// band center 20 -> band [0,40]; next group starts at line index 2 (start 40),
// block = lines[2..3] => [40, 76], center = 58.
expect(snapReadingRulerToLines(20, 400, 40, 2, 'forward', evenBoxes)).toBe(58);
});
it('moves backward to the previous block of N lines, centered on the block', () => {
const boxes = [40, 60, 80, 100, 120, 140, 160, 180, 200, 220].map((s) => ({
start: s,
end: s + 16,
}));
// band center 200 -> band [180,220]; last line fully above is index 6 (end 176),
// block = lines[5..6] => [140, 176], center = 158.
expect(snapReadingRulerToLines(200, 400, 40, 2, 'backward', boxes)).toBe(158);
});
it('returns null at the bottom boundary so the page can flip', () => {
const boxes = [
{ start: 0, end: 16 },
{ start: 20, end: 36 },
];
// band center 100 -> band [80,120]; no line starts below -> null.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBeNull();
});
it('returns null at the top boundary so the page can flip', () => {
const boxes = [
{ start: 80, end: 96 },
{ start: 100, end: 116 },
];
// band center 100 -> band [80,120]; no line ends above -> null.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'backward', boxes)).toBeNull();
});
it('clamps the snapped center so the band stays inside the viewport', () => {
const boxes = [{ start: 180, end: 196 }];
// forward target block center = 188, but dimension 200 / size 40 clamps to 180.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBe(180);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: FAIL — `snapReadingRulerToLines is not a function` (export missing).
- [ ] **Step 3: Implement `snapReadingRulerToLines`**
In `src/app/reader/utils/readingRuler.ts`, add after `stepReadingRulerPosition`:
```typescript
const clampCenterPx = (center: number, dimension: number, rulerSize: number): number => {
const half = rulerSize / 2;
if (half * 2 >= dimension) return dimension / 2;
return Math.max(half, Math.min(dimension - half, center));
};
/**
* Snap the ruler band to the next/previous block of `lines` real text lines,
* centered on that block. Returns the new band center in px, or null when there
* is no next group in the given direction (the caller then flips the page).
*/
export const snapReadingRulerToLines = (
currentCenterPx: number,
dimension: number,
rulerSize: number,
lines: number,
direction: 'backward' | 'forward',
lineBoxes: ReadingRulerLineBox[],
): number | null => {
if (lineBoxes.length === 0 || dimension <= 0) return null;
const count = Math.max(1, Math.floor(lines));
const heights = lineBoxes
.map((b) => b.end - b.start)
.filter((h) => h > 0)
.sort((a, b) => a - b);
const medianHeight = heights.length ? heights[Math.floor(heights.length / 2)] : 0;
const eps = medianHeight * 0.3;
const half = rulerSize / 2;
const bandStart = currentCenterPx - half;
const bandEnd = currentCenterPx + half;
if (direction === 'forward') {
const startIdx = lineBoxes.findIndex((b) => b.start >= bandEnd - eps);
if (startIdx === -1) return null;
const endIdx = Math.min(startIdx + count - 1, lineBoxes.length - 1);
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
return clampCenterPx(center, dimension, rulerSize);
}
let endIdx = -1;
for (let i = lineBoxes.length - 1; i >= 0; i--) {
if (lineBoxes[i].end <= bandStart + eps) {
endIdx = i;
break;
}
}
if (endIdx === -1) return null;
const startIdx = Math.max(endIdx - count + 1, 0);
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
return clampCenterPx(center, dimension, rulerSize);
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: PASS (all describe blocks, including the original ones).
- [ ] **Step 5: Commit**
```bash
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
git commit -m "feat(ruler): add snapReadingRulerToLines for line-aware stepping"
```
---
## Task 3: Wire line-aware snapping into `ReadingRuler.tsx`
**Files:**
- Modify: `src/app/reader/components/ReadingRuler.tsx`
Glue the pure functions in: padded band size for snap-capable books, a per-page line-box cache, and snapping in both the tap handler and the page-change auto-move, with the existing arithmetic path as fallback.
This task is verified by `pnpm lint` + `pnpm test` (the pure logic is fully covered by Tasks 12) and a manual check in the reader, since the behavior depends on live DOM range geometry that unit tests cannot reproduce.
- [ ] **Step 1: Update imports**
In `src/app/reader/components/ReadingRuler.tsx`, change the `@/types/book` import (line 4) from:
```typescript
import { BookFormat, ViewSettings } from '@/types/book';
```
to:
```typescript
import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book';
```
And change the `../utils/readingRuler` import (lines 12-16) from:
```typescript
import {
calculateReadingRulerSize,
clampReadingRulerPosition,
stepReadingRulerPosition,
} from '../utils/readingRuler';
```
to:
```typescript
import {
buildLineBoxes,
calculateReadingRulerSize,
clampReadingRulerPosition,
READING_RULER_LINE_PADDING_PX,
ReadingRulerLineBox,
snapReadingRulerToLines,
stepReadingRulerPosition,
} from '../utils/readingRuler';
```
- [ ] **Step 2: Compute `supportsLineSnap` and padded `rulerSize`**
Replace line 61:
```typescript
const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
```
with:
```typescript
const supportsLineSnap = !viewSettings.scrolled && !FIXED_LAYOUT_FORMATS.has(bookFormat);
const baseRulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
const rulerSize = baseRulerSize + (supportsLineSnap ? READING_RULER_LINE_PADDING_PX : 0);
```
- [ ] **Step 3: Add the line-box cache ref**
After the `currentPositionRef` declaration (line 59), add:
```typescript
const lineBoxesRef = useRef<ReadingRulerLineBox[]>([]);
```
- [ ] **Step 4: Keep the line-box cache in sync per page**
Immediately after the container-size effect (the `useEffect` that ends at line 118, returning `() => resizeObserver.disconnect()`), add a new effect:
```typescript
// Cache the visible line boxes for the current page so taps can snap to real lines.
useEffect(() => {
if (!supportsLineSnap) {
lineBoxesRef.current = [];
return;
}
const range = progress?.range ?? null;
const containerRect = containerRef.current?.getBoundingClientRect();
if (!range || !containerRect) {
lineBoxesRef.current = [];
return;
}
try {
const rects = Array.from(range.getClientRects());
lineBoxesRef.current = buildLineBoxes(rects, isVertical, rtl, containerRect);
} catch {
lineBoxesRef.current = [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
progress?.range,
progress?.pageinfo?.current,
containerSize.width,
containerSize.height,
isVertical,
rtl,
supportsLineSnap,
]);
```
- [ ] **Step 5: Snap on page-change auto-move**
Replace the `performAutoMove` function body (lines 193-215) with the version below. It tries the line snap first and keeps the existing first-visible-text offset as a fallback:
```typescript
const performAutoMove = (range: Range | null) => {
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return;
const containerDimension = isVertical ? containerRect.width : containerRect.height;
if (containerDimension <= 0) return;
if (supportsLineSnap && range) {
try {
const rects = Array.from(range.getClientRects());
const boxes = buildLineBoxes(rects, isVertical, rtl, containerRect);
lineBoxesRef.current = boxes;
// Align to the first line group from the top of the page.
const snapped = snapReadingRulerToLines(
-rulerSize,
containerDimension,
rulerSize,
lines,
'forward',
boxes,
);
if (snapped != null) {
setRulerPosition((snapped / containerDimension) * 100, true);
return;
}
} catch {
/* fall through to default offset */
}
}
const textPosition = getFirstVisibleTextPosition(range);
// For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr
const defaultOffset = isVertical
? rtl
? (viewSettings.marginRightPx ?? 44)
: (viewSettings.marginLeftPx ?? 44)
: (viewSettings.marginTopPx ?? 44);
const offset = textPosition ?? defaultOffset;
const targetPosition = clampPosition(
((offset + rulerSize / 2) / containerDimension) * 100,
containerDimension,
);
setRulerPosition(targetPosition, true);
};
```
Then add `lines` and `supportsLineSnap` to the auto-move effect's dependency array (the array currently ending at lines 232-242). It should read:
```typescript
}, [
progress?.pageinfo?.current,
viewSettings.scrolled,
isVertical,
rtl,
viewSettings.marginTopPx,
viewSettings.marginLeftPx,
viewSettings.marginRightPx,
rulerSize,
lines,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ] **Step 6: Snap in the tap/key move handler**
In the `reading-ruler-move` effect, replace the body from the `const nextPosition = stepReadingRulerPosition(...)` block through the `return true;` (lines 400-412) with:
```typescript
let nextPosition: number;
if (supportsLineSnap && lineBoxesRef.current.length > 0) {
const currentCenterPx = (currentPositionRef.current / 100) * dimension;
const snapped = snapReadingRulerToLines(
currentCenterPx,
dimension,
rulerSize,
lines,
detail.direction,
lineBoxesRef.current,
);
// No next line group in this direction: let the page flip instead.
if (snapped == null) return false;
nextPosition = (snapped / dimension) * 100;
} else {
nextPosition = stepReadingRulerPosition(
currentPositionRef.current,
dimension,
rulerSize,
detail.direction,
);
}
if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) {
return false;
}
setRulerPosition(nextPosition, true);
return true;
```
Then add `lines` and `supportsLineSnap` to that effect's dependency array (currently line 419) so it reads:
```typescript
}, [
bookKey,
containerSize.height,
containerSize.width,
isVertical,
lines,
rulerSize,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ] **Step 7: Type-check and lint**
Run: `pnpm lint`
Expected: PASS (no Biome errors, no tsgo type errors). If tsgo complains that `ReadingRulerLineBox` is unused, confirm Step 3 added the `lineBoxesRef` typed with it.
- [ ] **Step 8: Run the full unit suite**
Run: `pnpm test`
Expected: PASS — no regressions.
- [ ] **Step 9: Manual verification in the reader**
Start the web dev server (`pnpm dev-web`), open a reflowable EPUB, enable the reading ruler (Settings → Color/Layout → Reading Ruler), and confirm:
- Tapping the page advances the band so the next lines sit centered in it, with no manual adjustment needed across many taps.
- At the bottom of a page, a forward tap flips the page and the band lands centered on the first lines of the new page.
- Backward taps and (if available) a vertical-writing-mode book behave symmetrically.
- A fixed-layout PDF still uses the old fixed-step behavior (no errors).
- [ ] **Step 10: Commit**
```bash
git add apps/readest-app/src/app/reader/components/ReadingRuler.tsx
git commit -m "feat(ruler): snap reading ruler to real text lines on tap and page change"
```
---
## Self-Review Notes
- **Spec coverage:** snapping rule (Tasks 12), fixed-size + padding (`READING_RULER_LINE_PADDING_PX`, Task 3 Step 2), reflowable + vertical scope (`buildLineBoxes` mapping + `supportsLineSnap`), fallback matrix (scrolled/fixed-layout/missing range/boundary all route to `stepReadingRulerPosition` or page flip), per-page caching (Task 3 Step 4). All covered.
- **Type consistency:** `ReadingRulerLineBox { start; end }`, `buildLineBoxes(rects, isVertical, rtl, containerRect)`, and `snapReadingRulerToLines(currentCenterPx, dimension, rulerSize, lines, direction, lineBoxes)` are used identically in tests and glue.
- **No placeholders:** every code step contains complete code and exact run commands.
@@ -1,557 +0,0 @@
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a keyboard action (default `Shift+M`, "Text to Speech" section) that persists the sentence TTS is currently reading aloud as a normal highlight using the user's default style/color — eyes-off, silent, idempotent (skip duplicates).
**Architecture:** Connect the two existing owners through the app event bus. `TTSController` is the only place that knows both `view.tts` (current sentence Range) and the TTS section index, so it exposes `getSpokenSentence(): { cfi, text } | null`. `Annotator` owns highlight persistence/rendering, so it creates the note. The shortcut → `useBookShortcuts` dispatches `tts-highlight-sentence``useTTSControl` (holds the controller ref) resolves the sentence and dispatches `create-tts-highlight``Annotator` builds/persists/draws the highlight. The bug-prone create-or-skip decision is a pure, unit-tested helper.
**Tech Stack:** TypeScript, React, Zustand, foliate-js, Vitest. Spec: `docs/superpowers/specs/2026-05-30-tts-highlight-current-sentence-design.md`.
**Conventions:**
- Test-first (project rule `.agents/rules/test-first.md`): write the failing test, run it red, implement, run it green.
- Never use the `any` type (`.agents/rules/typescript.md`); the test code below casts mock objects via `as unknown as <Type>`, matching the existing suites.
- Run a single test file with `pnpm test <path>` (no `--`).
- Conventional commits with scope, e.g. `feat(tts): ...`. End every commit message body with:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
- Do not push during implementation; commit locally only.
---
## File Structure
| File | Change | Responsibility |
| ---- | ------ | -------------- |
| `src/app/reader/utils/annotatorUtil.ts` | Modify | Add pure `buildTTSSentenceHighlight()` (create-or-skip decision + BookNote assembly) |
| `src/__tests__/utils/annotator-util.test.ts` | Modify | Unit tests for `buildTTSSentenceHighlight` |
| `src/services/tts/TTSController.ts` | Modify | Add `getSpokenSentence()` resolver |
| `src/__tests__/services/tts-controller.test.ts` | Modify | Unit tests for `getSpokenSentence` |
| `src/helpers/shortcuts.ts` | Modify | Register `onTTSHighlightSentence` default binding |
| `src/app/reader/hooks/useBookShortcuts.ts` | Modify | Handler that dispatches `tts-highlight-sentence` |
| `src/app/reader/hooks/useTTSControl.ts` | Modify | Resolve sentence via controller, relay `create-tts-highlight` |
| `src/app/reader/components/annotator/Annotator.tsx` | Modify | Handle `create-tts-highlight`: build/persist/draw |
---
## Task 1: Pure helper `buildTTSSentenceHighlight`
**Files:**
- Modify: `src/app/reader/utils/annotatorUtil.ts`
- Test: `src/__tests__/utils/annotator-util.test.ts`
- [ ] **Step 1: Write the failing test**
Append to `src/__tests__/utils/annotator-util.test.ts`. Also add `buildTTSSentenceHighlight` to the existing import from `@/app/reader/utils/annotatorUtil` and `HighlightStyle`, `HighlightColor` to the existing `@/types/book` import:
```ts
describe('buildTTSSentenceHighlight', () => {
const params = {
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
text: 'A spoken sentence.',
style: 'highlight' as HighlightStyle,
color: 'yellow' as HighlightColor,
page: 7,
};
it('builds an annotation BookNote when none exists at the cfi', () => {
const note = buildTTSSentenceHighlight([], params, 1000);
expect(note).not.toBeNull();
expect(note).toMatchObject({
type: 'annotation',
cfi: params.cfi,
text: params.text,
style: 'highlight',
color: 'yellow',
page: 7,
note: '',
createdAt: 1000,
updatedAt: 1000,
});
expect(typeof note!.id).toBe('string');
expect(note!.id.length).toBeGreaterThan(0);
});
it('returns null (skip) when a live annotation already exists at the cfi', () => {
const existing: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([existing], params, 1000)).toBeNull();
});
it('builds when the only note at the cfi is soft-deleted', () => {
const deleted: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
deletedAt: 5,
};
expect(buildTTSSentenceHighlight([deleted], params, 1000)).not.toBeNull();
});
it('builds when the note at the cfi is a non-annotation (bookmark)', () => {
const bookmark: BookNote = {
id: 'b1',
type: 'bookmark',
cfi: params.cfi,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([bookmark], params, 1000)).not.toBeNull();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
Expected: FAIL — `buildTTSSentenceHighlight is not a function` / import error.
- [ ] **Step 3: Implement the helper**
In `src/app/reader/utils/annotatorUtil.ts`, extend the top imports and add the function. Change the `@/types/book` import line to include `HighlightStyle`, and add `uniqueId`:
```ts
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { uniqueId } from '@/utils/misc';
```
Add at the end of the file:
```ts
/**
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
* `null` when one already exists at the same CFI (idempotent — pressing the
* hotkey twice on the same sentence must not create a duplicate).
*
* `now` is injected so the result is deterministic for tests. A soft-deleted
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
* does not block creation — it mirrors the live-annotation predicate used by
* the selection-based highlight path in Annotator.tsx.
*/
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: {
cfi: string;
text: string;
style: HighlightStyle;
color: HighlightColor;
page?: number;
},
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null;
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
Expected: PASS (all `buildTTSSentenceHighlight` cases green, existing cases still green).
- [ ] **Step 5: Commit**
```bash
git add src/app/reader/utils/annotatorUtil.ts src/__tests__/utils/annotator-util.test.ts
git commit -m "$(cat <<'EOF'
feat(tts): add buildTTSSentenceHighlight helper for sentence highlights
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: `TTSController.getSpokenSentence()`
**Files:**
- Modify: `src/services/tts/TTSController.ts` (add method near `dispatchSpeakMark`, ~line 565)
- Test: `src/__tests__/services/tts-controller.test.ts`
- [ ] **Step 1: Write the failing test**
Append a new `describe` block inside the top-level `describe('TTSController', ...)` in `src/__tests__/services/tts-controller.test.ts` (e.g. after the `dispatchSpeakMark` block). It reuses the file's existing `controller`, `mockView`, and `createMockView` setup:
```ts
describe('getSpokenSentence', () => {
test('returns the trimmed text and cfi of the current sentence', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' A spoken sentence. ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toEqual({
cfi: 'cfi-current',
text: 'A spoken sentence.',
});
});
test('returns null when TTS is inactive (no view.tts)', () => {
// No initViewTTS: view.tts is null and the section index is -1.
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when there is no current range', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue(undefined),
} as unknown as FoliateView['tts'];
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when getCFI throws', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => 'x' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockImplementation(() => {
throw new Error('cfi failure');
});
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when the sentence text is only whitespace', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toBeNull();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
Expected: FAIL — `controller.getSpokenSentence is not a function`.
- [ ] **Step 3: Implement the method**
In `src/services/tts/TTSController.ts`, add this public method immediately above `dispatchSpeakMark(mark?: TTSMark)` (~line 565). It performs the same Range→CFI conversion `dispatchSpeakMark` already uses, reading the current sentence Range from the foliate TTS engine and the active TTS section index:
```ts
getSpokenSentence(): { cfi: string; text: string } | null {
const range = this.view.tts?.getLastRange();
if (!range || this.#ttsSectionIndex < 0) return null;
try {
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
const text = range.toString().trim();
if (!cfi || !text) return null;
return { cfi, text };
} catch {
return null;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
Expected: PASS (new cases green, existing cases still green).
- [ ] **Step 5: Commit**
```bash
git add src/services/tts/TTSController.ts src/__tests__/services/tts-controller.test.ts
git commit -m "$(cat <<'EOF'
feat(tts): expose getSpokenSentence on TTSController
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Register the default shortcut binding
**Files:**
- Modify: `src/helpers/shortcuts.ts` (TTS section, after `onTTSGoPreviousParagraph`, ~line 75)
There is no standalone unit test for the static registry; correctness is verified by `pnpm lint` (tsgo derives `ShortcutConfig` from this object) and by the wiring tasks. The new action automatically appears in the keyboard-shortcuts help dialog because its `section` is non-empty.
- [ ] **Step 1: Add the entry**
In `src/helpers/shortcuts.ts`, insert into `DEFAULT_SHORTCUTS` immediately after the `onTTSGoPreviousParagraph` block (line 75):
```ts
onTTSHighlightSentence: {
keys: ['shift+m'],
description: _('Highlight Current Sentence'),
section: 'Text to Speech',
},
```
- [ ] **Step 2: Type-check**
Run: `pnpm lint`
Expected: PASS. `ShortcutConfig` now includes `onTTSHighlightSentence`. (If `useBookShortcuts` is type-checked before Task 4 wires the handler, `useShortcuts` accepts a partial map, so this should not error on its own; if it does, proceed to Task 4 and re-run.)
- [ ] **Step 3: Commit**
```bash
git add src/helpers/shortcuts.ts
git commit -m "$(cat <<'EOF'
feat(tts): add default Shift+M binding for highlight-current-sentence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Dispatch the shortcut event from `useBookShortcuts`
**Files:**
- Modify: `src/app/reader/hooks/useBookShortcuts.ts` (handler ~after line 301; registration ~line 358)
- [ ] **Step 1: Add the handler**
In `src/app/reader/hooks/useBookShortcuts.ts`, add immediately after `ttsGoPreviousParagraph` (line 301), mirroring `ttsGoNextSentence`:
```ts
const ttsHighlightSentence = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
};
```
- [ ] **Step 2: Register the handler**
In the `useShortcuts({ ... })` map, add after the `onTTSGoPreviousParagraph: ttsGoPreviousParagraph,` line (line 358):
```ts
onTTSHighlightSentence: ttsHighlightSentence,
```
- [ ] **Step 3: Type-check**
Run: `pnpm lint`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/app/reader/hooks/useBookShortcuts.ts
git commit -m "$(cat <<'EOF'
feat(tts): dispatch tts-highlight-sentence from the shortcut handler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Resolve the sentence and relay it from `useTTSControl`
**Files:**
- Modify: `src/app/reader/hooks/useTTSControl.ts` (handler ~after line 81; effect registration lines 103-114)
- [ ] **Step 1: Add the handler**
In `src/app/reader/hooks/useTTSControl.ts`, add after `handleTTSBackward` (line 81), mirroring the bookKey-matched pattern of the existing TTS handlers:
```ts
const handleTTSHighlightSentence = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sentence = ttsControllerRef.current?.getSpokenSentence();
if (!sentence) return;
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
};
```
- [ ] **Step 2: Register/unregister in the existing effect**
In the `useEffect` at lines 103-119, add the `on`/`off` pair alongside the other TTS listeners:
```ts
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
eventDispatcher.on('tts-forward', handleTTSForward);
eventDispatcher.on('tts-backward', handleTTSBackward);
eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay);
eventDispatcher.on('tts-highlight-sentence', handleTTSHighlightSentence);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
eventDispatcher.off('tts-forward', handleTTSForward);
eventDispatcher.off('tts-backward', handleTTSBackward);
eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay);
eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence);
```
(Leave the existing `ttsControllerRef.current?.shutdown()` cleanup below unchanged.)
- [ ] **Step 3: Type-check**
Run: `pnpm lint`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/app/reader/hooks/useTTSControl.ts
git commit -m "$(cat <<'EOF'
feat(tts): resolve spoken sentence and relay create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Create the highlight in `Annotator`
**Files:**
- Modify: `src/app/reader/components/annotator/Annotator.tsx` (import line 50; handler near `handleHighlight` ~line 840; effect lines 535-545)
- [ ] **Step 1: Import the helper**
Change line 50 to add `buildTTSSentenceHighlight`:
```ts
import {
buildTTSSentenceHighlight,
getHighlightColorHex,
removeBookNoteOverlays,
} from '../../utils/annotatorUtil';
```
- [ ] **Step 2: Add the event handler**
Add immediately after `handleHighlight` (after its closing brace, ~line 840). It reads state freshly via store getters (the listener is registered with `[]` deps, so it must not close over render-time `config`/`settings`/`progress`), matching the fresh-read pattern in `onShowAnnotation`:
```ts
const handleCreateTTSHighlight = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string; cfi: string; text: string } | undefined;
if (!detail || detail.bookKey !== bookKey) return;
const { settings } = useSettingsStore.getState();
const style = settings.globalReadSettings.highlightStyle;
const color = settings.globalReadSettings.highlightStyles[style];
const { booknotes: annotations = [] } = getConfig(bookKey)!;
const page = getProgress(bookKey)?.page;
const annotation = buildTTSSentenceHighlight(
annotations,
{ cfi: detail.cfi, text: detail.text, style, color, page },
Date.now(),
);
if (!annotation) return;
annotations.push(annotation);
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) => view?.addAnnotation(annotation));
};
```
- [ ] **Step 3: Register/unregister in the existing mount effect**
In the `useEffect` at lines 535-545, add the `on`/`off` pair:
```ts
eventDispatcher.on('export-annotations', handleExportMarkdown);
eventDispatcher.on('clear-annotations', handleClearAnnotations);
eventDispatcher.on('import-annotations', handleImportAnnotations);
eventDispatcher.on('create-tts-highlight', handleCreateTTSHighlight);
return () => {
eventDispatcher.off('export-annotations', handleExportMarkdown);
eventDispatcher.off('clear-annotations', handleClearAnnotations);
eventDispatcher.off('import-annotations', handleImportAnnotations);
eventDispatcher.off('create-tts-highlight', handleCreateTTSHighlight);
};
```
- [ ] **Step 4: Type-check**
Run: `pnpm lint`
Expected: PASS. (`useSettingsStore` is already imported at line 13; `getConfig`, `getProgress`, `getViewsById`, `updateBooknotes`, `saveConfig`, `envConfig` are all already in scope per lines 80-85.)
- [ ] **Step 5: Commit**
```bash
git add src/app/reader/components/annotator/Annotator.tsx
git commit -m "$(cat <<'EOF'
feat(tts): persist current TTS sentence as a highlight on create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full unit suite**
Run: `pnpm test`
Expected: PASS (no regressions; the two new test blocks green).
- [ ] **Step 2: Lint + type-check**
Run: `pnpm lint`
Expected: PASS (Biome + tsgo clean).
- [ ] **Step 3: Manual smoke test (dev web)**
Run: `pnpm dev-web`, open a book, start TTS (`t`), let it read a sentence, then press `Shift+M`. Confirm:
- the spoken sentence gets a persistent highlight in the user's default color/style;
- pressing the hotkey again on the same sentence does **not** add a second highlight;
- pressing it while TTS is stopped does nothing (no error in console);
- the highlight survives reopening the book (persisted), and appears in the notebook/annotations list.
- the action shows up in the keyboard-shortcuts help dialog (`Shift+?`) under "Text to Speech".
- [ ] **Step 4: (Optional) i18n extraction**
The new `_('Highlight Current Sentence')` string uses key-as-content, so tests/lint pass without extraction. If desired, run the project i18n extraction (`/i18n` skill or `pnpm i18n`) to sync locale catalogs; this is not required for verification to pass and may touch unrelated locale files — keep it out of the feature commits if run.
---
## Notes for the implementer
- **No production test seams.** `getSpokenSentence` is tested by setting the private `#ttsSectionIndex` through the public `controller.initViewTTS(0)` path, then overriding `mockView.tts`/`mockView.getCFI` — exactly how the existing `forward`/`backward`/`start` tests in that suite operate.
- **Granularity is always sentence.** All TTS clients report only `'sentence'` from `getGranularities()`, so `view.tts.getLastRange()` is always a sentence Range — no word-vs-sentence branching is needed.
- **Two events, three components, by design.** Only `TTSController` (via `useTTSControl`) can produce the CFI; only `Annotator` owns highlight persistence. The relay mirrors the existing `tts-forward`/`tts-backward` shortcut pattern rather than duplicating annotation logic.
- **bookKey matching everywhere.** Both new handlers compare `detail.bookKey === bookKey`, so split-view (two open books) routes the highlight to the correct book.
```
@@ -1,307 +0,0 @@
# Gesture-Based Brightness Control (iOS / Android)
GitHub issue: https://github.com/readest/readest/issues/3021
## Summary
Add a left-edge vertical swipe gesture that adjusts screen brightness while
reading, without opening the menu. While adjusting, a vertical progress bar with
a Sun icon appears at the left edge to indicate the current brightness level.
The feature is gated to platforms with native brightness control (iOS and
Android — `appService.hasScreenBrightness`). It is on by default but can be
disabled via a setting, works in both paginated and scrolled modes, and persists
the chosen brightness across sessions.
## Locked decisions
- **Enablement**: on by default for iOS/Android, with an opt-out toggle in
**Settings → Behavior → Device**. The toggle doubles as the discoverability
surface and the escape hatch for accidental activation (CEO review, both
voices). New setting: `swipeBrightnessGesture: boolean` (default `true`) in
`SystemSettings`.
- **Persistence**: on release, save `screenBrightness` (0100) and set
`autoScreenBrightness = false`, exactly like the existing menu slider, so the
value survives restart and stays in sync with the slider. Undo path: the menu
slider's "System Screen Brightness" toggle re-enables auto-brightness (CEO
review: an accidental swipe silently disables auto-brightness; the undo must be
documented and reachable).
- **Scope**: core only. One opt-out toggle (above). No sensitivity setting, no
corner choice, no volume gesture, no haptics, no lock.
- **Gesture area**: left **10%** of the view width, in **both** paginated and
scrolled modes.
- **Direction**: swipe up = brighter, swipe down = dimmer. A full view-height
drag spans the full 0→100% range.
## Behavior
1. A touch begins inside the left 10% of the view width.
2. It activates as a brightness gesture once movement becomes dominantly
vertical (`|Δy| > |Δx|`) and passes a ~18px threshold. The threshold is
deliberately above incidental thumb-jitter (CEO review: 10px was too eager
for an always-on edge strip).
3. While active, device brightness updates live (throttled via
`requestAnimationFrame`), and the overlay shows the current level.
4. On release, the value is persisted (`screenBrightness` + `autoScreenBrightness
= false`) and the overlay fades out shortly after.
Brightness mapping: `next = clamp(startBrightness Δy / viewHeight, 0, 1)`,
where `startBrightness` is the device brightness captured at activation.
## Conflict suppression (key design point)
The existing iframe touch listeners (`FoliateViewer.tsx` ~line 326) are passive
and forward events via `postMessage` to `useTouchEvent` / the interceptor chain,
which drives page-flip swipes and the upward-swipe-to-toggle-UI behavior. In
scrolled mode the iframe also scrolls natively on a vertical drag.
Attach a **dedicated capture-phase, non-passive** touch listener on the iframe
`doc`: `addEventListener('touch{start,move,end,cancel}', fn, { capture: true,
passive: false })`. The callback is a parent-realm closure, so it can call the
device store and React state directly — no `postMessage` or interceptor needed.
**Why capture phase (corrected after eng review — this was a bug in the first
draft).** There are *three* independent touch-listener registrants on the same
`doc`: (1) FoliateViewer's own `postMessage` forwarders (`FoliateViewer.tsx:326`,
passive), (2) the Annotator's non-passive selection listeners (`Annotator.tsx:332`),
and (3) **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`,
non-passive, bubble-phase), registered during `view.open()` — i.e. *before* any
app-level listener. The paginator's `touchmove` can `preventDefault()`, set
`#touchScrolled`, and `scrollBy()`. A bubble-phase listener registered later
therefore **cannot** `stopImmediatePropagation` the paginator — it already ran.
Both eng voices (Claude + Codex) independently verified this against `paginator.js`.
A **capture-phase** listener fires before every bubble-phase listener regardless
of registration order, so its `stopImmediatePropagation` suppresses paginator,
Annotator, and FoliateViewer handlers alike.
When the gesture is active, each move/end/cancel calls `preventDefault()` +
`stopImmediatePropagation()`. The latter is the *sole* mechanism that suppresses
the conflicting page-flip / upward-swipe-to-toggle-UI (`useIframeEvents.ts:282` —
also 10px, vertical, left-edge-inclusive); there is no threshold gap to rely on.
The ~18px activation threshold only reduces *accidental* starts.
**Selection guard.** Before arming, the listener checks `doc.getSelection()`; if a
non-collapsed selection exists, it does not arm (mirrors `paginator.js:1622`). This
keeps a vertical text-selection drag that starts in the left strip from being
hijacked into a brightness change.
**Scrolled-mode timing.** In scrolled mode the paginator does not `preventDefault`
(it early-returns at `paginator.js:1613`); native container scroll is what moves
content. `preventDefault` only takes effect once called, so the pre-activation
travel (≤18px) would scroll before brightness takes over. Decision: see the
"Scrolled-strip reservation" item in the review report — the implementation
`preventDefault`s from the first move of any touch that *armed* in the strip
(reserve the strip), so there is no scroll-then-freeze jump.
Before arming, and for taps / horizontal swipes / touches outside the strip, the
listener does nothing, so normal page-turn taps and swipes are unaffected.
Required test: a short upward flick inside the left 10% must adjust brightness and
never toggle the toolbar (asserted by spying `stopImmediatePropagation` with a
fake paginator listener registered *first*, proving capture-phase suppression).
## Components
### `src/app/reader/utils/brightnessGesture.ts` (pure, unit-tested)
- `isInLeftEdge(x: number, viewWidth: number, edgeRatio = 0.1): boolean`.
**Use `screenX` + the parent `window.innerWidth`, NOT `clientX` /
`documentElement.clientWidth`.** In paginated mode foliate-js lays content out
as side-by-side columns, so the iframe document is many screens wide and
`clientX` is a document coordinate (a left-edge touch on a later page reports a
large `clientX`). `screenX` is the physical screen position; the listener runs
in the parent realm so `window.innerWidth` is the real app viewport. (Matches
how `useIframeEvents` / `usePagination` already do zone detection.)
- `shouldActivate(deltaX: number, deltaY: number, threshold: number): boolean`
— true when `|Δy| >= threshold && |Δy| > |Δx|`.
- `computeBrightness(startPos: number, deltaY: number, viewHeight: number): number`
— works in **perceptual position** space (01) to match the menu slider:
`pos = clamp(startPos deltaY / viewHeight, 0, 1)`, then brightness value =
`positionToValue(pos)`. Reuse the slider's `pow(0.5)` curve from `ColorPanel.tsx`
(extract `valueToPosition` / `positionToValue` into this module so there is one
source of truth — design review, both voices: a linear gesture would land on a
different number than the slider for the same finger travel). Always clamp the
seed to `[0,1]` and never feed the `-1` sentinel into the curve.
Constants: `BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1`,
`BRIGHTNESS_GESTURE_ACTIVATION_PX = 18`.
### `src/app/reader/hooks/useBrightnessGesture.ts`
Inert unless `appService.hasScreenBrightness` AND `settings.swipeBrightnessGesture`.
**Latest-closure ref (`latestRef`).** The listener is attached once per doc (the
`isEventListenersAdded` guard) from a `docLoadHandler` that itself is captured
with a `[view]` dependency — so it sees stale render values. Therefore the
listener must read everything runtime-variable from a single `latestRef` updated
each render (mirrors `handlePageFlipRef` / `useTouchInterceptor`): the live
`swipeBrightnessGesture` toggle, `viewSettings.scrolled` / `.vertical`, and the
seed brightness. It must NOT read values captured in the hook's render closure.
**Seed priming (async-race fix).** On mount (when `hasScreenBrightness`), prime a
`seedBrightnessRef`: if `settings.screenBrightness ≥ 0` use it, else
`await getScreenBrightness()`, clamped to `[0,1]`; fall back to `0.5` if the read
fails or returns `< 0`. Multi-pane coherence: seed each gesture-start from the
**shared** `settings.screenBrightness` (via `latestRef`), not a private per-book
cache, so two grid panes don't drift. A late-resolving seed must not overwrite a
value the user has already adjusted this gesture.
Owns refs: `touchStart`, `armed`, `active`, `startPos`, `rafId`, `hideTimer`,
`seedBrightnessRef`, `latestRef`. Exposes `registerBrightnessListeners(doc)` and
`{ overlayVisible, overlayLevel }`.
Listener logic (capture phase):
- **touchstart**: if `!latestRef.swipeBrightnessGesture` → ignore. If
`doc.getSelection()` is non-collapsed → ignore (selection guard). Else record
start `clientX/clientY`; `armed = isInLeftEdge(...)`.
- **touchmove**: if `armed`:
- scrolled mode → `preventDefault()` from this first move (reserve the strip; no
scroll-then-freeze jump).
- once `active || shouldActivate(...)`: set `active`, `preventDefault()`,
`stopImmediatePropagation()`, compute brightness via the perceptual curve,
coalesce `setScreenBrightness` through a single `requestAnimationFrame`
(store `rafId`, cancel the prior frame), set `{ overlayVisible, overlayLevel }`.
- **touchend / touchcancel**: if `active`: `preventDefault()`,
`stopImmediatePropagation()`, **cancel any pending `rafId`**, apply the final
level deterministically, persist (`saveSysSettings('screenBrightness',
round(value*100))` + `saveSysSettings('autoScreenBrightness', false)`), schedule
the overlay hide (`hideTimer`, ~600ms). Always reset `touchStart/armed/active`.
- **teardown**: on hook unmount, `cancelAnimationFrame(rafId)` and clear
`hideTimer`.
### `src/app/reader/components/BrightnessOverlay.tsx`
A self-contained **capsule** (its own surface — `bg-base-100/90`, `border-base-content/20`)
holding a `PiSun` icon (`react-icons/pi`), a vertical track (`bg-base-content/20`)
filled from the bottom to `overlayLevel` (`bg-base-content`), and a small numeric
`%` label (`Math.round(value*100)`). The capsule surface is required so the
overlay stays legible over any book background (white / sepia / black / image
themes) — a bare bar would vanish on a same-tone page (design review, both voices).
- **Position**: physical `left` + `env(safe-area-inset-left)`, vertically centered
within the inset-aware content box (not the raw viewport, so it never lands under
a half-open FooterBar). `z-[15]` (above the z-10 header/footer/Ribbon tier),
`pointer-events: none`. RTL: keep physical left (user-locked); set `dir="ltr"`
on the capsule so the `%` reads correctly.
- **Timing (color themes)**: fill height has **no CSS transition** (tracks the
finger 1:1); the capsule fades in fast (~100ms), holds ~500ms after release,
fades out ~200ms, then unmounts. Appears immediately on activation, never dims
the Sun icon with level (full opacity at 0%). 0% keeps the track + border + `0%`
visible; 100% fills flush.
- **e-ink (`[data-eink]`)**: `eink-bordered`, no shadow/gradient, 1px border, and
**no continuous animation** — quantize the visual fill to ~10% steps and drop
the fade (show/hide instantly) so the panel repaints a handful of times, not
60×/s. Device brightness still updates live; only the overlay repaint is stepped.
- **Reduced motion** (`prefers-reduced-motion: reduce`): drop the opacity fades,
show/hide instantly (the live fill is functional, not decorative).
- `aria-hidden` (transient; the labeled menu slider is the canonical control).
Positioned `absolute` within the per-book view container (sibling of the iframe in
FoliateViewer), so in a multi-pane grid each book's overlay stays in its own pane.
### `src/app/reader/components/FoliateViewer.tsx`
- `const { registerBrightnessListeners, overlayVisible, overlayLevel } =
useBrightnessGesture(bookKey)`.
- Call `registerBrightnessListeners(detail.doc)` inside the existing
`isEventListenersAdded` block. (Capture-phase registration makes ordering moot,
but keep it in this block so it shares the doc lifecycle.)
- Render `<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />` as a
sibling of the iframe container.
### `src/types/settings.ts` + `src/services/constants.ts`
Add `swipeBrightnessGesture: boolean` to `SystemSettings` (default `true` in
`DEFAULT_SYSTEM_SETTINGS`). Surface the toggle in the settings UI under
**Behavior → Device** (next to the existing "System Screen Brightness" control in
`ControlPanel.tsx`), gated on `appService.hasScreenBrightness`. i18n: add the
label + description strings.
## Testing
The pure helpers are the easy part; the bug-prone logic is in the listener. Both
layers get tests (eng review: the first draft tested only the trivial helpers).
- **Pure helpers** (`brightnessGesture.test.ts`, failing-first):
edge detection at the exact 10% boundary; `shouldActivate` at the 18px boundary
and the `|Δy| == |Δx|` tie; perceptual curve round-trip
(`positionToValue(valueToPosition(v)) ≈ v`); up = brighter sign; `[0,1]` clamp
including an unseeded / `-1` start.
- **Listener-level integration** (`useBrightnessGesture.test.ts`): build a fake
`Document`, call `registerBrightnessListeners`, dispatch synthetic
touchstart/move/end/cancel sequences, and assert:
- capture-phase suppression — register a fake paginator listener *first*; a
left-strip upward flick must call `stopImmediatePropagation` so the fake never
fires (this is the test that fails with bubble-phase, proving the fix);
- horizontal swipe in the strip → not activated (page-flip preserved);
- selection-in-progress in the strip → not hijacked;
- scrolled mode → `preventDefault` called on the armed pre-activation move;
- gating → inert when `!hasScreenBrightness` or `!swipeBrightnessGesture`;
- persistence on end → `saveSysSettings('screenBrightness', …)` +
`autoScreenBrightness=false` (mock `saveSysSettings`/`setScreenBrightness`);
- `rafId` / `hideTimer` cancelled on touchend and unmount.
- **Manual on device**: live feel, overlay appear/fade + e-ink stepped repaint,
scroll reservation in scrolled mode, taps / page-turn swipes still work, and the
Settings → Behavior → Device toggle disables the gesture.
## Out of scope (deferred)
Sensitivity setting, corner/edge choice, right-edge volume gesture, haptic
feedback, gesture lock. These are listed in the issue but explicitly deferred.
## What already exists (reused, not rebuilt)
- `deviceStore.getScreenBrightness/setScreenBrightness` (01) → live brightness.
- `saveSysSettings` + the `screenBrightness` / `autoScreenBrightness` settings →
persistence, identical to the menu slider.
- `ColorPanel.tsx` perceptual `pow(0.5)` curve → extracted and shared.
- `useTouchInterceptor` / `handlePageFlipRef` → latest-closure ref precedent.
- `Annotator.tsx` non-passive doc listener → precedent (we go one further: capture).
- `appService.hasScreenBrightness` → platform gate.
---
## GSTACK REVIEW REPORT (/autoplan — CEO + Design + Eng, dual voices)
Mode: SELECTIVE EXPANSION. Codex + Claude subagent per phase. Premise confirmed
by user. Plan revised in place per the findings below.
### Consensus
- **CEO**: premise CONFIRMED (parity with Moon+/KOReader). Both voices challenged
"no toggle", "left edge", "10px". User decided: add opt-out toggle
(Settings→Behavior→Device, default on); keep left edge; raise to 18px.
- **Design**: e-ink stepped repaint (no continuous animation), self-contained
contrast capsule, perceptual-curve + `%` label, fill-vs-fade timing split,
`z-[15]` + `pointer-events:none`, reduced-motion, RTL physical-left + `dir=ltr`.
- **Eng**: 🔴 the original bubble-phase + `stopImmediatePropagation` suppression
was **refuted by both voices** (foliate-js paginator registers first, bubble) →
**capture-phase** non-passive listener + `touchcancel`. Plus: selection guard,
eager+clamped brightness seed, rAF/timer teardown, shared-settings seed for
multi-pane, iframe-doc coordinate space, listener-level test harness.
### Cross-phase theme
**Gesture-conflict correctness** surfaced in all three phases (CEO flagged the
rationale as unverified; both Eng voices proved it false). Highest-confidence
signal — the capture-phase fix is the single most important change.
### Decision Audit Trail
| # | Phase | Decision | Class | Principle | Rationale |
|---|-------|----------|-------|-----------|-----------|
| 1 | CEO | Ship the gesture | Premise gate | — | User confirmed; parity feature |
| 2 | CEO | Add opt-out toggle (default on) | User challenge | — | User chose: Settings→Behavior→Device |
| 3 | CEO | Keep left edge | User challenge | — | User chose; preserves overlay placement |
| 4 | CEO | Threshold 10→18px | Taste | P5 explicit | Both voices: 10px too eager |
| 5 | CEO | Document auto-brightness undo path | Mechanical | P1 | Silent side-effect needs reachable undo |
| 6 | Eng | Capture-phase listener + touchcancel | Mechanical | P5 | Both voices proved bubble-phase insufficient |
| 7 | Eng | Selection guard before arming | Mechanical | P1 | Prevent selection hijack in strip |
| 8 | Eng | Eager + clamped brightness seed | Mechanical | P1 | Fix async race on default -1/auto |
| 9 | Eng | rAF / hide-timer teardown | Mechanical | P1 | Prevent stale write / leak |
| 10 | Eng | Listener-level integration tests | Mechanical | P1 | Cover the actual bug-prone branches |
| 11 | Design | Perceptual curve reuse + `%` | Mechanical | P4 DRY | One source of truth vs slider |
| 12 | Design | e-ink stepped, no continuous anim | Mechanical | P1 | Project e-ink rules |
| 13 | Design | Contrast capsule surface | Mechanical | P1 | Legible over any book theme |
| T1 | Eng | Scrolled-strip reservation (preventDefault from arm) | **Taste — open** | P5 | See final gate |
@@ -1,153 +0,0 @@
# Line-Aware Reading Ruler — Design
**Date:** 2026-05-29
**Status:** Approved (pending spec review)
## Problem
The reading ruler is a band overlay that helps the user track lines while reading.
Tapping the screen advances the band forward/backward by exactly one ruler height
(`stepReadingRulerPosition` in `src/app/reader/utils/readingRuler.ts`), where the
ruler height is computed arithmetically as `lines × fontSize × lineHeight`.
That arithmetic height rarely matches the *actual* rendered line-to-line distance:
fonts, inline images, headings, ruby text, and CSS overrides all shift real line
positions. The error accumulates across taps, so text drifts out of the band's
center and the user must manually drag the ruler to recenter it — which makes the
feature tedious and undercuts its purpose.
The on-page-change auto-move (`ReadingRuler.tsx`, the `getFirstVisibleTextPosition`
effect) already aligns to the first visible line using real geometry, but
within-page taps do not.
## Goal
Make tap-advance **line-aware**: snap the band to the next group of `lines` *actual*
rendered lines, centered on that block, so text stays centered in the band without
manual adjustment. Drift is eliminated because every step is computed from real
line geometry rather than an accumulating arithmetic offset.
## Decisions (locked)
- **Sizing model:** Fixed band size — keep the configured band height; snap *position*
to real lines rather than dynamically resizing per tap. A small constant padding is
added to the rendered band so the centered lines are fully contained with breathing
room.
- **Scope:** Reflowable horizontal **and** vertical writing mode. Fixed-layout
(PDF/CBZ) and scrolled mode keep the current arithmetic stepping as fallback.
- **Geometry source:** Reuse `progress.range` (Approach A). `progress.range` already
spans the entire first-to-last *visible* text (foliate `#getVisibleRange`,
`packages/foliate-js/paginator.js`), and the existing auto-move already reads
`progress.range.getClientRects()` mapped to container coords. Since a paginated page
does not scroll, those rects are stable for every tap on that page.
## Snapping rule
The band advances by exactly `lines` *real* lines per tap and is centered on that
block's midpoint:
- **Forward:** find the first line whose start is at/after the current band's far edge
(within a small epsilon). Take that line plus the next `lines 1` lines to form a
block `[blockStart, blockEnd]`. The new band center is `(blockStart + blockEnd) / 2`,
clamped to the viewport.
- **Backward:** symmetric — find the `lines` lines ending just before the current
band's near edge and center on their block midpoint.
- **No next group** (already at the last/first line group, or no line geometry):
return `null`. The caller falls back to today's behavior — page flip on tap, or
`stepReadingRulerPosition` where appropriate.
"Center on the block midpoint" guarantees that, regardless of how the configured band
height compares to the real line block, the lines sit centered with equal clipping on
either side in the worst case.
## Architecture
### Pure logic — `src/app/reader/utils/readingRuler.ts`
Two new pure functions (no DOM), unit-tested before implementation:
- `buildLineBoxes(rects, isVertical, rtl, containerRect): LineBox[]`
- Input: plain rect-like objects (from `range.getClientRects()`), orientation flags,
and the container rect for coordinate mapping.
- Clusters per-fragment rects into visual lines by overlap on the cross axis
(horizontal: cluster by vertical overlap → line tops/bottoms; vertical: cluster by
horizontal overlap → column spans). Maps to the ruler axis in container coordinates,
matching the existing auto-move mapping (`rect.top containerRect.top` for
horizontal; distance-from-edge for vertical-rl/lr).
- Returns a sorted array of `{ start, end }` spans along the ruler axis.
- `snapReadingRulerToLines(currentCenterPx, dimension, lines, direction, lineBoxes): number | null`
- Implements the snapping rule above. Returns the next band center in px, or `null`
when there is no next group to advance to.
Existing `clampReadingRulerPosition`, `stepReadingRulerPosition`,
`getReadingRulerMoveDirection`, and `calculateReadingRulerSize` are unchanged and remain
the fallback path.
### Glue — `src/app/reader/components/ReadingRuler.tsx`
- Memoize line boxes per page: rebuild from `progress.range.getClientRects()` when the
page (`progress.pageinfo.current`) / range changes or the container size changes.
- The page-change auto-move effect and the `reading-ruler-move` tap handler both:
1. Compute the snap target via `snapReadingRulerToLines`.
2. If it returns a value, animate the band to it (existing `setRulerPosition(_, true)`).
3. If it returns `null`, fall back to the current logic
(`stepReadingRulerPosition` / no-op so the page flip proceeds).
- Render the band at `rulerSize + READING_RULER_LINE_PADDING_PX` so centered lines are
fully contained. The padding affects only the rendered band and its overlay/clamp
math, not the line-advance computation.
### Fallback matrix
| Condition | Behavior |
| -------------------------------------- | --------------------------------- |
| Reflowable/vertical, line boxes found | Line-aware snap |
| `progress.range` missing / no rects | `stepReadingRulerPosition` (arith)|
| Scrolled mode | Existing behavior (unchanged) |
| Fixed-layout (PDF/CBZ) | `stepReadingRulerPosition` (arith)|
| Snap returns `null` (at boundary) | Page flip / no movement (as today)|
## Testing
Test-first, per project rule. Pure-function unit tests added to the existing
`src/__tests__/utils/readingRuler.test.ts`:
- `buildLineBoxes`: clusters multi-fragment rects into correct line spans; horizontal vs
vertical-rl vs vertical-lr mapping; ignores zero-size rects; sorted output.
- `snapReadingRulerToLines`: forward/backward advance by exactly `lines`; centers on
block midpoint; returns `null` at boundaries; respects clamping; degenerate inputs
(empty `lineBoxes`, `lines` larger than available).
Then implement and verify with `pnpm test` and `pnpm lint`.
## Multi-column layouts (column-aware band)
In paginated layouts that render more than one column (`view.renderer.columnCount > 1`),
clustering visible lines by vertical position alone tangles the two columns once their
line grids drift apart (headings/images), causing the band to jump erratically.
For multi-column horizontal layouts the ruler is **column-aware and spans one column at
a time**:
- `buildReadingRulerColumns(rects, columnCount, overlayWidth, rtl)` groups the
overlay-relative rects into columns by x (bucketed by `overlayWidth / columnCount`),
then into line boxes within each column, returned in reading order.
- Rects are mapped to overlay coordinates with the iframe's frame offset
(`frameRect.left/top`), because paginated multi-column pages shift the iframe far
off-screen horizontally; vertical-only mapping is insufficient here.
- `snapReadingRulerColumns(columnIndex, centerPx, …, columns)` advances within the
active column; at the column's end it moves to the first/last group of the
next/previous column; past the last/first column it returns `null` → page flip.
- The band renders over the active column's horizontal extent; the rest of the page —
**including the inactive column** — is dimmed (top/bottom/left/right dim rects).
- Single column collapses to the full-width band (one column spanning the viewport);
vertical writing mode keeps the flat `buildLineBoxes`/`snapReadingRulerToLines` path.
## Non-goals / YAGNI
- No dynamic per-tap band resizing.
- No new foliate-js APIs or TreeWalker enumeration (Approach B) — reuse `progress.range`.
- No `caretPositionFromPoint` probing (Approach C).
- No changes to scrolled mode or fixed-layout ruler behavior beyond keeping them on the
existing fallback path.
- No new user-facing settings.
@@ -1,241 +0,0 @@
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Design
**Date:** 2026-05-30
**Status:** Approved (pending spec review)
**Issue:** [#4085](https://github.com/readest/readest/issues/4085)
## Problem
While TTS is reading aloud, there is no way to persist the sentence currently
being spoken as a highlight without stopping playback, locating the text
visually, and dragging to select it. That defeats the purpose of hands-free /
eyes-off TTS reading and is inaccessible to users with motor or visual
impairments — exactly the audience that benefits most from "react with a single
key" capture.
Today the only workarounds are to stop TTS and select manually, or to take notes
outside Readest (which then aren't synced/exported with the book).
## Goal
Add a single configurable keyboard action that, while TTS is active, persists the
**sentence currently being read aloud** as a normal highlight — same data model,
persistence, rendering, sync, and export as any other highlight — using the
user's current default highlight style/color. No selection, no looking at the
screen.
## Decisions (locked)
- **Default keybinding:** `Shift+M` ("M for mark"), in the existing
**"Text to Speech"** shortcut section. A single, easy-to-hit key best fits the
eyes-off accessibility motivation. Chosen over the issue's suggested `Ctrl+H`
(already bound to *Highlight Selection*), over `Ctrl/Cmd+Shift+H` (the `cmd+`
variant collides with browser/OS `Cmd+Shift+H` on macOS, and the matcher fires
on any listed key regardless of platform), over `Shift+H` (already an alias for
*Go Back*), and over "unbound by default" (the #3772 customization UI does not
exist yet, so an unbound default would be unreachable by normal users). `Shift+M`
is currently unused, has no browser/OS collision, and behaves identically on all
platforms; it remains customizable once #3772 ships.
- **Repeat press = skip:** If the current sentence is already highlighted, do
nothing. No duplicate, no toggle-delete (an accidental eyes-off repeat must
never destroy a highlight), no color cycling.
- **Silent:** No toast/confirmation. The highlight simply appears.
- **Style/color:** The user's current default —
`settings.globalReadSettings.highlightStyle` and
`highlightStyles[style]` — identical to a default selection highlight.
- **Granularity:** The spoken unit is always a **sentence**. All three TTS
clients (`WebSpeechClient`, `EdgeTTSClient`, `NativeTTSClient`) report only
`'sentence'` granularity via `getGranularities()`, so `view.tts.getLastRange()`
always returns the current sentence Range (never a word).
## Why this wiring
Two existing owners must cooperate, and the design connects them rather than
duplicating their responsibilities:
- Only `TTSController` knows **both** `view.tts` (the foliate TTS engine — source
of the current sentence Range) **and** `#ttsSectionIndex` (required to convert
that Range into a CFI). It already performs exactly this conversion in
`dispatchSpeakMark` (`this.view.getCFI(this.#ttsSectionIndex, range)`).
- Only `Annotator` owns highlight creation — the `BookNote` data model,
idempotency, persistence (`updateBooknotes` + `saveConfig`), rendering
(`view.addAnnotation`), and global-annotation fan-out.
They are connected through the app event bus (`eventDispatcher`), mirroring the
existing `tts-forward` / `tts-backward` / `tts-toggle-play` shortcuts that
`useBookShortcuts` dispatches and `useTTSControl` handles against the controller
ref.
## Data flow
```
User presses Shift+M
→ useBookShortcuts: onTTSHighlightSentence handler
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey })
→ useTTSControl: 'tts-highlight-sentence' handler (owns the TTSController ref)
const sentence = ttsController.getSpokenSentence(); // { cfi, text } | null
if (sentence) eventDispatcher.dispatch('create-tts-highlight',
{ bookKey, ...sentence });
→ Annotator: 'create-tts-highlight' handler
build a BookNote (type 'annotation', default style/color, cfi, text);
skip if a non-deleted annotation already exists at that cfi;
else updateBooknotes + saveConfig + view.addAnnotation(annotation).
```
## Architecture
### 1. Shortcut definition — `src/helpers/shortcuts.ts`
Add to `DEFAULT_SHORTCUTS`:
```ts
onTTSHighlightSentence: {
keys: ['shift+m'],
description: _('Highlight Current Sentence'),
section: 'Text to Speech',
},
```
`ShortcutConfig` is derived from `DEFAULT_SHORTCUTS`, so the type updates
automatically and the action appears in the shortcuts help dialog with no extra
work. When the #3772 customization UI lands, this action is customizable like any
other.
### 2. Current-sentence resolver — `src/services/tts/TTSController.ts`
New public method, the single source of truth for "what sentence is being
spoken right now":
```ts
getSpokenSentence(): { cfi: string; text: string } | null {
const range = this.view.tts?.getLastRange();
if (!range || this.#ttsSectionIndex < 0) return null;
try {
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
const text = range.toString().trim();
if (!cfi || !text) return null;
return { cfi, text };
} catch {
return null;
}
}
```
Returns `null` when TTS is inactive (`view.tts` is cleared on `shutdown`) or no
sentence is current — the natural no-op gate. Works whether TTS is playing or
paused (a paused controller keeps `view.tts` and the last mark).
### 3. Shortcut handler — `src/app/reader/hooks/useBookShortcuts.ts`
New handler mirroring `ttsGoNextSentence`, registered as `onTTSHighlightSentence`:
```ts
const ttsHighlightSentence = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
};
```
### 4. Resolver glue — `src/app/reader/hooks/useTTSControl.ts`
New handler for `'tts-highlight-sentence'`, registered/cleaned up alongside the
existing TTS event listeners (`tts-forward`, `tts-backward`, …):
```ts
const handleTTSHighlightSentence = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sentence = ttsControllerRef.current?.getSpokenSentence();
if (!sentence) return;
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
};
```
### 5. Highlight creation — pure helper + Annotator event handler
The selection path (`handleHighlight`) is selection-coupled and does more than
the TTS path needs (it reads `selection`, computes the CFI from
`selection.range`, updates an existing note, carries the `global` flag, and
drives popup/selection UI state). The TTS path is strictly simpler — create or
skip — so rather than overloading `handleHighlight`, the bug-prone *decision* is
extracted as a pure, unit-testable helper and the Annotator keeps the
React/persistence glue.
**Pure helper — `src/app/reader/utils/annotatorUtil.ts`:**
```ts
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: { cfi: string; text: string; style: HighlightStyle; color: HighlightColor; page?: number },
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null; // idempotent: skip duplicates (locked decision)
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}
```
This mirrors the idempotency predicate already used inline in `handleHighlight`
(`annotations.findIndex(a => a.cfi === cfi && a.type === 'annotation' && a.style
&& !a.deletedAt)`). `now` is injected so the helper is deterministic for tests.
**Annotator — `src/app/reader/components/annotator/Annotator.tsx`:**
A new `'create-tts-highlight'` event handler (bookKey-matched), subscribed
alongside the other `eventDispatcher` listeners: read default `style`/`color`
from `settings.globalReadSettings`, call `buildTTSSentenceHighlight`; if it
returns `null`, do nothing; otherwise push the note, `updateBooknotes` +
`saveConfig`, and `view.addAnnotation(annotation)` for each view — the same
persistence/render calls the selection path makes.
## Edge cases
| Condition | Behavior |
| --------- | -------- |
| TTS not playing / no current sentence | `getSpokenSentence()``null` → silent no-op |
| Sentence already highlighted | idempotency skip — no duplicate (locked decision) |
| TTS reading a not-yet-visible section | CFI uses the TTS section index; note is saved and draws when that section renders (existing `onCreateOverlay` path) |
| Live gray TTS cursor overlay overlaps the new highlight | distinct overlay keys; the gray cursor moves on as TTS advances, leaving the persistent highlight |
| Wrong book (split view) | every handler is bookKey-matched, as the existing TTS handlers are |
## Testing
Test-first, per project rule. Both new units are testable with **no production
test seams**:
- **`TTSController.getSpokenSentence()`** — add to
`src/__tests__/services/tts-controller.test.ts`. The private
`#ttsSectionIndex` is set through the public `await controller.initViewTTS(0)`
path (already exercised in that suite); `mockView.tts.getLastRange` and
`mockView.getCFI` are stubbed via the existing `createMockView` helper.
Cases: returns `{ cfi, text }` (trimmed) when a sentence is active; returns
`null` when `view.tts` is absent (pre-init), when `getLastRange()` is
undefined, when `getCFI` throws, and when the range text is empty/whitespace.
- **`buildTTSSentenceHighlight`** — add to
`src/__tests__/utils/annotator-util.test.ts`. Cases: builds a `BookNote`
(`type: 'annotation'`, given `cfi`/`text`/`style`/`color`/`page`, injected
timestamps) when none exists; returns `null` when a non-deleted annotation
already exists at that `cfi`; still builds when the only match at that `cfi`
is `deletedAt`-soft-deleted or a non-annotation (`bookmark`).
Then verify with `pnpm test` and `pnpm lint`.
## Non-goals / YAGNI
- No toast/confirmation, no haptics (locked: silent).
- No toggle-to-remove and no color cycling on repeat (locked: skip).
- No shortcut-customization UI — that is #3772; this only adds one entry to the
existing registry, which #3772 will expose.
- No word-level highlighting (#4017) — out of scope; granularity is always
sentence in practice.
- No new user-facing settings.
- No change to selection-based highlighting behavior.
-110
View File
@@ -1,110 +0,0 @@
# Testing
Readest uses three test tiers, all powered by [Vitest](https://vitest.dev/).
## Unit Tests (`pnpm test`)
Runs tests in a **jsdom** environment. No browser or Tauri runtime required.
```bash
pnpm test # Run all unit tests
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single file
pnpm test -- --watch # Watch mode
```
- **Config:** `vitest.config.mts`
- **Pattern:** `src/**/*.test.ts` (excludes `*.browser.test.ts` and `*.tauri.test.ts`)
- **Environment:** jsdom
- **Use for:** Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
## Browser Tests (`pnpm test:browser`)
Runs tests in a **real Chromium** browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
```bash
pnpm test:browser
```
- **Config:** `vitest.browser.config.mts`
- **Pattern:** `src/**/*.browser.test.ts`
- **Browser:** Chromium (headless, via `@vitest/browser-playwright`)
- **Use for:** WASM modules (e.g. `@tursodatabase/database-wasm`), Web Worker integration, browser-specific storage APIs.
## Tauri Integration Tests (`pnpm test:tauri`)
Runs Vitest tests **inside the Tauri WebView**, with access to Tauri IPC and native plugin commands. Tests execute in the actual app environment.
### Step 1: Start the Tauri App
In one terminal, start the app with the `webdriver` feature enabled:
```bash
pnpm tauri:dev:test # Dev mode (uses tauri dev server, faster iteration)
pnpm tauri:build:test # Debug release build (closer to production)
```
These commands compile the Rust backend with `--features webdriver`, which:
- Includes `tauri-plugin-webdriver` (embeds a W3C WebDriver server on port 4445)
- Adds a runtime capability granting plugin permissions to remote URLs (`http://127.0.0.1:*`), so Vitest's browser-mode iframe can call Tauri IPC
Keep this running while you run tests.
### Step 2: Run Tests
In another terminal:
```bash
pnpm test:tauri
```
Vitest connects directly to the embedded WebDriver server (port 4445) in the running Tauri app and executes tests inside its WebView.
- **Config:** `vitest.tauri.config.mts`
- **Pattern:** `src/**/*.tauri.test.ts`
- **Browser provider:** `@vitest/browser-webdriverio` (connects to port 4445)
- **Use for:** Tauri plugin commands (turso, native-tts, etc.), native filesystem, Tauri IPC.
### Writing Tauri Tests
Tests access Tauri IPC via a shared helper:
```typescript
import { invoke } from '../tauri/tauri-invoke';
it('calls a plugin command', async () => {
const result = await invoke('plugin:turso|load', { options: { path: 'sqlite::memory:' } });
expect(result).toBeDefined();
});
```
The `invoke()` helper accesses `window.top.__TAURI_INTERNALS__` (Vitest runs in an iframe, Tauri injects IPC into the main frame).
**Limitations:** Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. `@tauri-apps/api`) that rely on `URL: local` may not work from the Vitest iframe.
## E2E Tests (WDIO)
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
```bash
# Terminal 1: start the app (same as for Tauri integration tests)
pnpm tauri:dev:test
# Terminal 2: run E2E tests
pnpm test:e2e
```
- **Config:** `wdio.conf.ts`
- **Pattern:** `e2e/**/*.e2e.ts`
- **Framework:** Mocha (via `@wdio/mocha-framework`)
- **Connects to:** port 4445 (embedded WebDriver server)
- **Use for:** UI interaction tests, window management, navigation flows.
## Test File Naming
| Suffix | Runner | Environment |
| ------------------- | ------------------- | --------------------- |
| `*.test.ts` | `pnpm test` | jsdom |
| `*.browser.test.ts` | `pnpm test:browser` | Chromium (Playwright) |
| `*.tauri.test.ts` | `pnpm test:tauri` | Tauri WebView |
| `*.e2e.ts` | `pnpm test:e2e` | Tauri app (WDIO) |
-209
View File
@@ -1,209 +0,0 @@
## Adding a Config to `ViewSettings`
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
This doc covers how to plumb a new config through the three layers:
1. **Types**`src/types/book.ts`
2. **Defaults**`src/services/constants.ts` and `src/services/settingsService.ts`
3. **Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
### Pick a Pattern
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
### Step 1 — Declare the Type
**Pattern A** — add a required field to the sub-interface that owns this concern:
```ts
// src/types/book.ts
export interface ViewConfig {
// ...existing fields
myNewToggle: boolean;
}
```
**Pattern B** — define a new interface and extend `ViewSettings`:
```ts
// src/types/book.ts
export interface ViewSettingsConfig {
isGlobal: boolean;
}
export interface ViewSettings
extends
BookLayout,
BookStyle,
// ...other bundles
ViewSettingsConfig {}
```
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
### Step 2 — Provide a Default
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
```ts
// src/services/constants.ts
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
// ...existing defaults
myNewToggle: false,
};
```
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
```ts
// src/services/constants.ts
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
isGlobal: true,
};
```
```ts
// src/services/settingsService.ts
export function getDefaultViewSettings(ctx: Context): ViewSettings {
return {
...DEFAULT_BOOK_LAYOUT,
...DEFAULT_BOOK_STYLE,
// ...other bundles
...DEFAULT_VIEW_SETTINGS_CONFIG,
// platform overrides go last so they win
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
};
}
```
#### Platform Overrides
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
#### Migration
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
```ts
settings.globalViewSettings = {
...getDefaultViewSettings(ctx),
...settings.globalViewSettings,
};
```
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
### Step 3 — Read and Write from Components
Read the current value by preferring the per-book settings, falling back to the global:
```tsx
const { settings } = useSettingsStore();
const { getViewSettings } = useReaderStore();
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
```
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
```tsx
import { saveViewSettings } from '@/helpers/settings';
const [myNewToggle, setMyNewToggle] = useState(viewSettings.myNewToggle);
useEffect(() => {
saveViewSettings(envConfig, bookKey, 'myNewToggle', myNewToggle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [myNewToggle]);
```
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
#### Signature
```ts
saveViewSettings<K extends keyof ViewSettings>(
envConfig,
bookKey,
key: K,
value: ViewSettings[K],
skipGlobal = false, // true → only update this book's settings
applyStyles = true, // false → don't re-run style recomputation
)
```
**Global vs. per-book routing.** `saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
```tsx
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
```
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
### Step 4 — Support Reset
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
```tsx
const resetToDefaults = useResetViewSettings();
const handleReset = () => {
resetToDefaults({
myNewToggle: setMyNewToggle,
// ...other setters
});
};
```
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
### Step 5 — Register in the Command Palette
If your setting has a visible row in a panel, register it in the matching `*PanelItems` array in `src/services/commandRegistry.ts`. This wires it into the command-palette fuzzy search so users can jump straight to it.
```ts
// src/services/commandRegistry.ts
const layoutPanelItems = [
// ...existing entries
{
id: 'settings.layout.myNewToggle',
labelKey: _('My New Toggle'),
keywords: ['search', 'terms', 'for', 'discoverability'],
section: 'Paragraph',
},
];
```
- `id` must match the `data-setting-id` attribute on the panel row. The palette uses it to scroll/highlight the target control.
- `labelKey` uses `stubTranslation` (imported as `_`) so the extractor picks it up — the same string that appears in the panel.
- `keywords` broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.
- `section` groups the entry in the palette results (matches the panel's sub-header: `Layout`, `Paragraph`, `Page`, `Header & Footer`, etc.).
Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
### Don'ts
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
### Minimal Checklist
- [ ] Field or new interface added in `src/types/book.ts`
- [ ] Default value in `src/services/constants.ts`
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
- [ ] Command-palette entry added to the matching `*PanelItems` array in `src/services/commandRegistry.ts`, with an `id` that matches the panel row's `data-setting-id`
-48
View File
@@ -1,48 +0,0 @@
# End-to-end tests
Readest has two end-to-end lanes. They cover different layers and are run
separately.
## Web lane — Playwright
Drives the Next.js **web** build (`pnpm dev-web`) in a real browser. Fast, no
Rust build required. Tests run unauthenticated against a fresh browser
context, so each test starts from an isolated, empty local library.
```bash
pnpm test:e2e:web # run the web e2e suite (auto-starts pnpm dev-web)
pnpm test:e2e:web:headed # run headed, one test at a time, with traces
pnpm test:e2e:web:ui # run in the Playwright UI mode
pnpm test:e2e:web:report # open the last HTML report
```
Every run writes an HTML report to `playwright-report/`; open it with
`pnpm test:e2e:web:report`.
Layout:
| Path | Purpose |
| --------------------------------- | ------------------------------------------------------ |
| `playwright.config.ts` (app root) | Runner config, projects, web server. |
| `e2e/tests/` | Specs (`*.spec.ts`). |
| `e2e/pages/` | Page Object Model — actions/queries, no assertions. |
| `e2e/fixtures/` | Shared fixtures; `fixtures/books/` holds sample books. |
Page objects expose locators and actions; assertions stay in the specs so
failures point at test intent. To add coverage, prefer extending a page
object over inlining selectors in a spec.
The demo-book auto-import (`useDemoBooks`) is suppressed by the base fixture
so the library is deterministic; authenticated/sync flows are out of scope
for this lane until a test account is provisioned.
## Tauri lane — WebdriverIO
Drives the actual **Tauri** desktop shell via `tauri-driver`. Use this for
coverage that depends on the native build (Rust integration, window
management, platform globals).
```bash
pnpm tauri:dev:test # start the Tauri app with the webdriver feature
pnpm test:e2e # run wdio against it (specs: e2e/*.e2e.ts)
```
-123
View File
@@ -1,123 +0,0 @@
describe('Readest App Launch', () => {
it('should have a visible body element', async () => {
const body = await $('body');
await body.waitForDisplayed({ timeout: 10000 });
expect(await body.isDisplayed()).toBe(true);
});
it('should have the correct window handle', async () => {
const handle = await browser.getWindowHandle();
expect(handle).toBeTruthy();
});
it('should return the page source', async () => {
const source = await browser.getPageSource();
expect(source).toContain('html');
});
});
describe('Library Page', () => {
it('should navigate to the library page', async () => {
const url = await browser.getUrl();
expect(url).toMatch(/library|localhost/);
});
it('should display the library container', async () => {
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
it('should display the library header', async () => {
const header = await $('[aria-label="Library Header"]');
await header.waitForExist({ timeout: 10000 });
expect(await header.isExisting()).toBe(true);
});
it('should display the bookshelf area', async () => {
const bookshelf = await $('[aria-label="Bookshelf"]');
await bookshelf.waitForExist({ timeout: 10000 });
expect(await bookshelf.isExisting()).toBe(true);
});
it('should have a search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForExist({ timeout: 10000 });
expect(await searchInput.isExisting()).toBe(true);
});
it('should allow typing in the search input', async () => {
const searchInput = await $('.search-input');
await searchInput.waitForDisplayed({ timeout: 10000 });
await searchInput.setValue('test search');
const value = await searchInput.getValue();
expect(value).toBe('test search');
});
it('should show the clear search button after typing', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.waitForExist({ timeout: 5000 });
expect(await clearBtn.isExisting()).toBe(true);
});
it('should clear the search input when clear button is clicked', async () => {
const clearBtn = await $('[aria-label="Clear Search"]');
await clearBtn.click();
const searchInput = await $('.search-input');
const value = await searchInput.getValue();
expect(value).toBe('');
});
it('should have a select books button', async () => {
const selectBtn = await $('[aria-label="Select Books"]');
await selectBtn.waitForExist({ timeout: 10000 });
expect(await selectBtn.isExisting()).toBe(true);
});
it('should have an import books button', async () => {
const importBtn = await $('[aria-label="Import Books"]');
await importBtn.waitForExist({ timeout: 10000 });
expect(await importBtn.isExisting()).toBe(true);
});
});
describe('Window Management', () => {
it('should return the window size', async () => {
const size = await browser.getWindowSize();
expect(size.width).toBeGreaterThan(0);
expect(size.height).toBeGreaterThan(0);
});
});
describe('JavaScript Execution', () => {
it('should execute JavaScript in the app context', async () => {
const result = await browser.execute(() => {
return document.readyState;
});
expect(result).toBe('complete');
});
it('should access the document title via JS', async () => {
const title = await browser.execute(() => {
return document.title;
});
expect(title).toContain('Readest');
});
it('should detect the app platform globals', async () => {
const hasCLIAccess = await browser.execute(() => {
return (window as unknown as Record<string, unknown>).__READEST_CLI_ACCESS === true;
});
expect(hasCLIAccess).toBe(true);
});
});
describe('Navigation', () => {
it('should navigate back to library after visiting another route', async () => {
const currentUrl = await browser.getUrl();
await browser.url(currentUrl.replace(/\/[^/]*$/, '/library'));
const library = await $('[aria-label="Your Library"]');
await library.waitForExist({ timeout: 15000 });
expect(await library.isExisting()).toBe(true);
});
});
-49
View File
@@ -1,49 +0,0 @@
import { test as base, expect } from '@playwright/test';
import { LibraryPage } from '../pages/LibraryPage';
import { ReaderPage } from '../pages/ReaderPage';
import { SAMPLE_EPUB } from './books';
type Fixtures = {
/**
* Imports a book (the sample EPUB by default), opens it, and returns a
* {@link ReaderPage} that is ready to interact with.
*/
openBook: (filePath?: string) => Promise<ReaderPage>;
};
/**
* Base test fixture for the web e2e lane.
*
* - Overrides `page` to suppress the demo-book auto-import that `useDemoBooks`
* performs on a fresh web session (see `src/app/library/hooks/useDemoBooks.ts`),
* so every test starts from a deterministic empty library.
* - Adds the `openBook` action fixture so reading/annotation specs do not
* repeat the import-and-open boilerplate.
*/
export const test = base.extend<Fixtures>({
page: async ({ page }, use) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem('demoBooksFetched', 'true');
} catch {
// localStorage may be unavailable in some contexts; ignore.
}
});
await use(page);
},
openBook: async ({ page }, use) => {
await use(async (filePath = SAMPLE_EPUB) => {
const library = new LibraryPage(page);
await library.goto();
await library.importBook(filePath);
await expect(library.bookCards()).toHaveCount(1);
await library.openFirstBook();
const reader = new ReaderPage(page);
await reader.waitForReady();
return reader;
});
},
});
export { expect };
-17
View File
@@ -1,17 +0,0 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const fixturesDir = path.dirname(fileURLToPath(import.meta.url));
/** Synthetic plain-text book — fast, used for basic import coverage. */
export const SAMPLE_TXT = path.join(fixturesDir, 'books/readest-e2e-sample.txt');
/**
* A real EPUB ("Alice's Adventures in Wonderland") from the unit-test
* fixtures. Has multiple chapters and substantial prose, so it exercises
* reading and annotation flows realistically.
*/
export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
@@ -1,29 +0,0 @@
Readest E2E Sample
Chapter One
This is a synthetic plain-text book used by the Readest web end-to-end
test suite. It exists only so that import and reading flows have a small,
deterministic file to exercise. It is not a real publication.
The text below is intentionally repetitive filler. Its only purpose is to
give the paginator enough content to lay out across more than one page so
that page-turning can be exercised by the tests.
Chapter Two
A reader opens a book, and the words arrange themselves into pages. Each
page is a small window onto a longer whole. Turn forward and the window
slides ahead; turn back and it returns to where it was.
Lorem ipsum has long served as placeholder prose, but plain repetition
serves a test suite just as well. Sentences accumulate, paragraphs follow,
and soon there is enough material for the layout engine to work with.
Chapter Three
The end of a sample book is much like its beginning: a few lines of text,
arranged for no one in particular, standing in for the real thing. When
the test completes, this book is discarded and forgotten.
The End
-12
View File
@@ -1,12 +0,0 @@
import type { Page } from '@playwright/test';
/**
* Shared base for page objects.
*
* Page objects expose actions and queries (locators) only assertions live
* in the specs, so a failing assertion points at test intent rather than at a
* helper.
*/
export abstract class BasePage {
constructor(protected readonly page: Page) {}
}

Some files were not shown because too many files have changed in this diff Show More