Compare commits

..

1 Commits

Author SHA1 Message Date
chrox 4f20ae4967 Add NEXT_PUBLIC_APP_PLATFORM env variable in github workflow 2024-12-06 22:49:36 +01:00
1843 changed files with 17265 additions and 366843 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']
-23
View File
@@ -1,23 +0,0 @@
---
name: Feature request
about: Share an idea or suggestion
title: 'FR: describing your feature request'
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 [...]
**Describe the solution you'd like**
> 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.
**Additional context**
> 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
```
-13
View File
@@ -1,13 +0,0 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- '*' # 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"
-246
View File
@@ -1,246 +0,0 @@
name: PR checks
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
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
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
# 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
cache: pnpm
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- 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
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
+59 -359
View File
@@ -5,121 +5,33 @@ on:
release:
types: [published]
permissions: read-all
jobs:
get-release:
create-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 }}
release_id: ${{ steps.create-release.outputs.result }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v3
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v3
- 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
- name: create release
id: create-release
uses: actions/github-script@v6
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo,
})
core.setOutput('release_id', data.id);
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require('fs');
const version = require('./apps/readest-app/package.json').version;
const releaseNotesFileContent = fs.readFileSync('./apps/readest-app/release-notes.json', 'utf8');
const releaseNotes = JSON.parse(releaseNotesFileContent).releases[version] || {};
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
return data.id
build-tauri:
needs: get-release
needs: create-release
permissions:
contents: write
strategy:
@@ -127,192 +39,69 @@ jobs:
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'
- 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: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v3
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
node-version: 24
version: 9.14.4
- name: Setup node
uses: actions/setup-node@v3
with:
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
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
workspaces: apps/readest-app/src-tauri -> target
- 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
- name: create .env.local file for Next.js
- 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_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_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_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-latest'
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/
# Use -f so curl fails on HTTP errors instead of writing the 404 body
# ("Not Found") into latest.json and clobbering the release asset with
# invalid JSON, which then breaks tauri-action's updater merge on every
# subsequent build.
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
exit 1
fi
if ! jq empty latest.json 2>/dev/null; then
echo "::error::Existing latest.json is not valid JSON; aborting."
exit 1
fi
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 }}
@@ -321,118 +110,29 @@ 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 || '' }}
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
- 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)
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 }}
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
elif [ "$arch" = "aarch64" ]; then
bin_file="Readest_${version}_arm64-portable.exe"
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
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: |
# Use -f so curl fails on HTTP errors instead of writing the 404 body
# ("Not Found") into latest.json and clobbering the release asset with
# invalid JSON, which then breaks tauri-action's updater merge on every
# subsequent build.
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
exit 1
fi
if ! jq empty latest.json 2>/dev/null; then
echo "::error::Existing latest.json is not valid JSON; aborting."
exit 1
fi
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]
publish-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: [create-release, build-tauri]
steps:
- name: publish release
id: publish-release
uses: actions/github-script@v6
env:
release_id: ${{ needs.create-release.outputs.release_id }}
with:
script: |
github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: process.env.release_id,
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
-74
View File
@@ -1,74 +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:
contents: read
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"
-23
View File
@@ -1,23 +0,0 @@
name: Deploy to vercel on merge
on:
push:
branches:
- main
permissions:
contents: read
jobs:
build_and_deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-comment: false
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"
}
}
}
-104
View File
@@ -1,104 +0,0 @@
# Contribution Guidelines
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.
- 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.
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.
Basically you need to install or update the following development tools:
- **Node.js** and **pnpm** for Next.js development
- **Rust** and **Cargo** for Tauri development
```bash
nvm install v22
nvm use v22
npm install -g pnpm
rustup update
```
### Getting Started
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
cd readest
git submodule update --init --recursive
```
#### 2. Install Dependencies
```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
```
#### 3. Verify Dependencies Installation
To confirm that all dependencies are correctly installed, run the following command:
```bash
pnpm tauri info
```
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
#### 4. Build for Development
```bash
pnpm tauri dev
```
#### 5. Build for Production
```bash
pnpm tauri build
```
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:
| Command | Description |
| ---------------- | -------------------------------------------------- |
| `pnpm dev-web` | Starts the development server for the web app only |
| `pnpm build-web` | Builds the web app |
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)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
Check that your code follows the project's style guidelines by running:
```bash
pnpm build
```
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
## Credits
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
Generated
-10826
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
+67 -237
View File
@@ -1,26 +1,20 @@
<div align="center">
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
<img src="https://github.com/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
</a>
<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]
<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]
[![OS][badge-platforms]][link-website]
[![][badge-discord]][link-discord]
<br>
[![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 +24,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="#contributing">Contributing</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/readest_landing_preview.png" alt="Readest Banner" width="100%" />
</a>
</div>
@@ -45,85 +38,71 @@
<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. | ✅ |
## 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. | 🛠 |
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🔄 |
| **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. 😊
## Screenshots
![Annotations](./data/screenshots/annotations.png)
![Annotations](./data/screenshots/annotations.jpeg)
![TTS](./data/screenshots/tts_speak_aloud.png)
![Wikipedia](./data/screenshots/wikipedia.jpeg)
![DeepL](./data/screenshots/deepl.png)
![DeepL](./data/screenshots/deepl.jpeg)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Theming Dark Mode](./data/screenshots/theming_dark_mode.png)
![Dark Mode](./data/screenshots/dark_mode.jpeg)
---
## 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
- **Node.js** and **pnpm** for Next.js development
- **Rust** and **Cargo** for Tauri development
- **Rust and Cargo** for Tauri development
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
```
@@ -135,18 +114,18 @@ 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
```
### 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,201 +143,52 @@ 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
## Contributing
### 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
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.
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
## 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.
<!---
npx contributor-faces --exclude "*bot*" --limit 100"
--->
<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" />
</p>
</a>
[//]: contributor-faces
## Support
<a href="https://github.com/chrox"><img src="https://avatars.githubusercontent.com/u/751535?v=4" title="chrox" width="50" height="50"></a>
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>
[//]: contributor-faces
## 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:
- [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.
Readest is distributed under the AGPL-3.0 License. See the [LICENSE](<(LICENSE)>) file for details.
---
<div align="center" style="color: gray;">Happy reading with Readest!</div>
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
[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-license]: https://img.shields.io/github/license/chrox/readest?color=teal
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
[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/chrox/readest?color=green
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/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
[link-gh-pulse]: https://github.com/readest/readest/pulse
[link-gh-wiki]: https://github.com/readest/readest/wiki
[link-discord]: https://discord.gg/gntyVNk3BJ
[link-gh-releases]: https://github.com/chrox/readest/releases
[link-gh-commits]: https://github.com/chrox/readest/commits/main
[link-gh-pulse]: https://github.com/chrox/readest/pulse
[link-discord]: https://discord.gg/jb2nzDts
[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
-84
View File
@@ -1,84 +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)
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
- [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
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
## 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
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
## Testing
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified``published` divergence fix
## Build & Vendoring
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
## Platform Compat
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
## 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/`
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)``writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
## 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
## Reader UI Fixes
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
## Library Fixes
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
## 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()`)
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
## 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,31 +0,0 @@
---
name: android-image-callout-freeze
description: "Android WebView native long-press image callout collides with app touch handlers and freezes the app; reusable `.no-context-menu` fix"
metadata:
node_type: memory
type: project
originSessionId: 50bec34f-7090-4bf4-a194-9bf4029527bf
---
Recurring Android-only freeze: long-pressing an `<img>` triggers the WebView's
native image callout (context menu / drag / magnifier) which collides with the
app's own touch handlers (long-press multi-select, or pinch/pan) and freezes the
whole app until restart.
**Root cause:** `-webkit-touch-callout: none` does NOT inherit, so a
`.no-context-menu` class on a *container* never reaches descendant images.
**Fix:** the `.no-context-menu img, .no-context-menu a` rule in
`src/styles/globals.css` (sets `-webkit-touch-callout: none; -webkit-user-drag:
none; user-select: none`). Apply the `no-context-menu` class to an *ancestor* of
the image so the descendant rule reaches it. Harmless on desktop
(`-webkit-touch-callout` is a no-op there; right-click-save still works).
Occurrences so far:
- Book covers on the bookshelf — PR #4345 (`BookshelfItem.tsx`, gated on
`appService?.isMobileApp`; added the `.no-context-menu img` rule).
- Image preview / zoom viewer — issue #4420, `ImageViewer.tsx` root container
(applied unconditionally — no selectable text there, so no need to gate).
**How to apply:** when a new "Android freezes on long-press of an image" report
comes in, find the `<img>` and put `no-context-menu` on a containing element.
@@ -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,48 +0,0 @@
---
name: booknote-view-autoscroll-4352
description: "Annotation/bookmark list (BooknoteView) auto-scroll-to-nearest regression after virtualization (#4352) and its TOCView-mirroring fix"
metadata:
node_type: memory
type: project
originSessionId: bda988b9-28ec-450f-874e-ee9c104f7603
---
After #4352 virtualized `BooknoteView` (sidebar annotations/bookmarks list,
`src/app/reader/components/sidebar/BooknoteView.tsx`), the list stopped
auto-scrolling to the nearest annotation for the current reading position (it
stranded at the top showing Chapter 1). #4352 replaced the per-item
`useScrollToItem` with a single `virtuosoRef.scrollToIndex`, but missed the
machinery `TOCView` already had. Two distinct failure paths:
1. **Reload (annotations tab active at load; progress arrives AFTER mount):** the
OverlayScrollbars `initialized` callback (deferred init) resets the wrapped
viewport `scrollTop` to 0, clobbering the scroll; the `lastScrolledCfiRef`
guard then blocks any retry. Fix = re-apply `scrollToIndex` to the *current*
nearest index inside the `initialized` callback, read via a **ref**
(`nearestIndexRef`) because that callback is the mount-time closure. Double
rAF (settle the reset, then re-assert once rows are measured).
2. **Tab-switch (open panel while reading; progress KNOWN at mount):** firing a
`scrollToIndex` synchronously on the freshly mounted, unmeasured list either
no-ops (`behavior:'smooth'`) or **wedges Virtuoso into rendering nothing**
(`behavior:'auto'`). Fix = mount Virtuoso *natively* centered via
`initialTopMostItemIndex` + an `initialScrollHandledRef` gate so the scroll
effect SKIPS that first jump. This is exactly TOCView's design.
The fix mirrors [[toc-expand-and-autoscroll]] (same OverlayScrollbars-resets-
scrollTop + Virtuoso-lands-short-on-unmeasured-rows pattern). Test:
`src/__tests__/components/BooknoteView.test.tsx` stubs Virtuoso (spy-able
`scrollToIndex`, captures `initialTopMostItemIndex`) and captures the mount-time
`initialized` callback — forcing a ref-based fix (modeled on `TOCView.test.tsx`).
**Dev-server verification gotchas (cost hours here):**
- The `localhost:3000` dev server was running from a *different worktree*
(`/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed`), not the main checkout.
Edits weren't compiled until copied into that worktree's path. Check
`ps aux | grep next-server` for the serving cwd. Book data is per-origin
(OPFS/IndexedDB on localhost:3000) so you can't verify on another port.
- After ~10 rapid file syncs, **Fast Refresh corrupts the mounted tab's state**
— identical code that worked started rendering 0 items. A *brand-new tab*
(close the old one) renders correctly. Always verify in a fresh tab.
- Chrome MCP `javascript_tool` querying `.booknote-item` count catches Virtuoso
mid-render (returns 0 even when it later paints fine). Trust the *screenshot*
(the painted frame), not a synchronous DOM count, for virtualized lists.
@@ -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,27 +0,0 @@
---
name: custom-fonts-reincarnation-4410
description: Custom fonts/textures disappear when logged into cloud sync after re-import-after-delete; CRDT remove-wins needs a reincarnation token
metadata:
node_type: memory
type: project
originSessionId: 2b61b392-4d32-4516-84bd-f362bba22378
---
# #4410 — disappearing custom fonts when logged into cloud
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId``addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false``softDeleteByContentId` → font disappears.
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
**Coverage matrix across collection kinds (all share the remove-wins replica):**
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
@@ -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,21 +0,0 @@
---
name: duokan-fullscreen-cover-scroll
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
metadata:
node_type: memory
type: project
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
---
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
**Root cause**`View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
**Key facts**
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
@@ -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,25 +0,0 @@
---
name: iframe-cross-realm-instanceof
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
metadata:
node_type: memory
type: project
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
---
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
yet are dead in the running app.
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
(numeric constant, realm-safe) or check `classList`/`closest` presence.
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
never tripped. Related: [[foliate-touch-listener-capture-phase]].
@@ -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,18 +0,0 @@
---
name: koplugin-cover-upload
description: "koplugin cover system — where local/cloud covers come from, and the"
metadata:
node_type: memory
type: reference
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
---
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
@@ -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,47 +0,0 @@
---
name: paginated-texture-occlusion-4399
description: Background texture absent in paginated mode (shown in scrolled) — opaque
metadata:
node_type: memory
type: project
originSessionId: 1bd8a73e-f279-4ed8-9562-98e96d9723d5
---
RESOLVED — merged. foliate-js `142bf11` (on main) + readest pointer bump/test
(`fix(reader): show background texture in paginated mode (#4399)`).
#4399: a background texture (Settings → Color → texture, e.g. Leaves) shows in
**scrolled** mode but is **absent in paginated** mode. The texture is NOT in the
iframe — it's mounted on the HOST as `.foliate-viewer::before` (`src/styles/textures.ts`,
`mountBackgroundTexture`): `position:absolute; inset:0; z-index:0; mix-blend-mode:multiply;
opacity:.6` over the reader container. For it to show, the whole foliate view tree
(iframe page bg + the paginator `#background` layer) must be transparent so the host
`::before` composites over the white page fill that a **parent** element provides
(`oklch(1 0 0)` on the reader grid cell, NOT on `.foliate-viewer`, which is transparent).
**Root cause.** foliate-js `paginator.js` `#replaceBackground`. Scrolled mode already
left things transparent under a texture (`#background.style.background = hasTexture ? '' : fallbackBg`
+ blanking transparent view elements). The **paginated** branch hard-set
`this.#background.style.background = fallbackBg` (opaque theme color, e.g. white) on the
segment **container** — that opaque container, a descendant of `.foliate-viewer`, paints
over the host `::before` texture. The per-view *segments* were already transparent for
transparent pages (`rgba(0,0,0,0)`); only the container was the occluder. Verified live
via Chrome MCP: `#background` inline bg was `rgb(255,255,255)`; setting it `''` revealed
the leaves texture instantly.
**Regression** = commit `167757a` "Fix background flash when swiping between
differently-colored pages" (2026-05-31, see [[paginator-swipe-bg-flash]]). The OLD
paginated path was a CSS `grid` of per-column divs and never set the `#background`
container background (kept the `''` reset), so a transparent page let the texture through.
167757a swapped to `computeBackgroundSegments` and added the opaque container line.
**Fix.** Extract a shared exported pure helper `textureAwareBackground(resolved, hasTexture)`
→ returns `''` when `hasTexture && isTransparent(resolved)`, else `resolved`. Use it for
BOTH scrolled view elements and paginated segment bgs, and set the paginated container
`this.#background.style.background = hasTexture ? '' : fallbackBg` (mirroring scrolled).
No-texture path unchanged → swipe-flash fix for colored pages fully preserved; a
book-forced opaque page still paints its segment (texture correctly does not show there).
Test: `src/__tests__/document/paginator-background-segments.test.ts` (new `describe` for
the pure helper, alongside `computeBackgroundSegments`). foliate-js is a submodule —
commit there + bump the pointer. See [[paginator-swipe-bg-flash]].
@@ -1,71 +0,0 @@
---
name: paginator-gutter-bleed-asymmetry-4394
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
metadata:
node_type: memory
type: project
originSessionId: 108a70bd-5af9-4a31-9b93-9d6df7637579
---
#4394 "page viewport offset / content clipping." On a 2-up cover spread the
left page (cover `<img>`, transparent page bg) had a white outer gutter while
the right page (`<body class="c3">` body-colour yellow) bled its colour past its
column into the right outer gutter → spread looked shifted right. On a wide
screen (1920px, 720 max-inline) the gutters are ~250px so it reads as a "massive
white blank gap." 0.10.6 filled colour edge-to-edge; 0.11.2 regressed.
**Geometry.** Grid `#top` = `minmax(--_outer-min-left,1fr) margin-left
minmax(0,maxw) margin-right minmax(--_outer-min-right,1fr)` (foliate
`paginator.js`). `#container` = grid-column 2/5 (inset by the outer gutter
tracks); `#background` = grid-column 1/-1 (spans the gutters). `--_outer-min =
(col_count-1)*(margin/4 + gap/4)` — non-zero ONLY in multi-column (0 in
1-column), added in commit `0a0ceda` so the outer margin matches half the
inter-column gap (symmetric spacing). `inset = containerLeft - bgLeft =
--_outer-min-left`.
**Cause.** `computeBackgroundSegments` (the swipe-flash fix, commit `167757a`)
positioned each page's colour segment at `inset + offset - scrollPos` and then
STRETCHED any segment touching a container edge OUT to the bg edge (`start=0` /
`end=bgSize`) — i.e. bled into the outer gutter. A body-coloured page bled into
its gutter; a transparent/image page (cover) did not (its `#background` is
transparent; its `<img>` is clamped inside the inset `#container`). Mixed spread
→ one gutter coloured, one not → asymmetric.
**Fix (what the maintainer wanted).** Do NOT touch `--_outer-min` (it keeps the
left/right margins symmetric with the centre gap). Instead CLAMP each segment to
the content area so the background stays inside its column and never overflows
into the outer gutter:
```js
const start = Math.max(segStart, containerStart) // was: if(...) start = 0
const end = Math.min(segEnd, containerEnd) // was: if(...) end = bgSize
if (end <= start) continue
```
`containerStart=inset`, `containerEnd=inset+containerSize`. In single-column the
gutters are 0 (`--_outer-min`=0 → inset 0) so this still fills the viewport edge
to edge (matches 0.10.6); in multi-column each page stays in its column with
symmetric gutter margins. Image pages (cover/彩页) were already correct (`<img>`
sits in its column); only the body-colour `#background` overflow needed fixing.
**Dead ends (took 2 wrong tries before the maintainer steered me right).**
1. Thought it was the gutter-bleed and "gated" the stretch on both-edges-coloured
— maintainer: "nothing to do with the gutter" (meaning don't change the bleed
GATING, the real issue is the offset).
2. Thought the title page shouldn't be yellow at all — it genuinely is
`.c3{background:#ffe43f}` on `<body>`, captured as `docBackground` and painted
full-bleed (body is zeroed via `doc.body.style.background='none'`, the old
f087826 "dismiss iframe background, paint via root" design). Yellow is correct,
it was just overflowing.
3. Proposed dropping `--_outer-min` (revert `0a0ceda`) — maintainer rejected:
that desymmetrises the centre-vs-side gaps. Keep the grid; fix the background.
**Verifying live.** foliate-js is a submodule; `next dev` / turbopack did NOT
hot-reload an edit to `packages/foliate-js/paginator.js` even across full
navigations — had to RESTART the dev server (`pnpm dev-web`, port 3000) to pick
it up. Cheap interim check: clamp the live `#background` segment divs in the page
and screenshot. Segment colours are render-timing dependent (a probe right after
nav can show all-transparent before the body-colour is captured).
Tests: `apps/readest-app/src/__tests__/document/paginator-background-segments.test.ts`
(tests "centered page" + "two-up spread" flipped from full-bleed to confined; the
inset=0 swipe-flash tests are unchanged). Related: [[paginator-swipe-bg-flash]]
[[paginated-texture-occlusion-4399]].
@@ -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,21 +0,0 @@
---
name: pdfjs-vendor-wasm-decoders
description: Scanned PDFs blank in CI builds but fine locally — pdfjs wasm decoders (jbig2.wasm) not copied to public/vendor/pdfjs
metadata:
node_type: memory
type: project
originSessionId: 9066c0b0-71a6-4db0-92c1-c3ccacf1ff0d
---
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
**Root cause:** `pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
**Why local masked it:** `pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
**Gotchas for future:**
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
- `cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
- Related: [[platform-compat-fixes]], [[bug-patterns]].
@@ -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,18 +0,0 @@
---
name: progressbar-focus-ring-4397
description: ProgressBar footer painted a stray focus-ring line on Android long-press; fix = remove tabIndex from the decorative div
metadata:
node_type: memory
type: project
originSessionId: 7d650c44-2d60-4a88-a9f5-6b5956bca59b
---
#4397 "Strange line on the bottom of the page when long-pressing the footer" (Android 13, 0.11.2).
**Symptom:** a thin horizontal line, content-column wide, across the last text line / bottom margin. Appears after long-pressing the footer + turning a page; persists across page turns; cleared by double-tapping the footer + turning a page. Maintainer couldn't repro on eink (device/WebView-specific long-press focus behavior).
**Root cause:** `ProgressBar.tsx` (the always-on page-info footer, `.progressinfo`) rendered as `<div role='presentation' tabIndex={-1} onClick=...>`. The `tabIndex={-1}` made the decorative div click/touch-focusable. Android WebView long-press focused it and painted the browser default focus ring (`outline: auto`, matched `:focus-visible`). It's a top-document element pinned `absolute bottom-0` at book-view width, so the ring shows as a content-width box at the bottom on *every* page until focus clears. NOT a range/slider input — those are `opacity-0`/`visibility:hidden` (red herring: globals.css excludes `input[type='range']` from its outline-suppression rule, but that's unrelated here).
**Fix:** remove `tabIndex={-1}`. A `role='presentation'` decorative element must not be focusable. `onClick` (tap-to-cycle progress mode / dismiss annotation popup) fires regardless of tabindex, and no ancestor has a real `tabindex` to grab focus instead (verified: focus falls through to `<body>`). Nothing programmatically focuses `.progressinfo`. Test asserts `.progressinfo` has no `tabindex` attribute (in `ProgressBar.test.tsx`).
**Debugging technique that worked:** download the issue video (`gh` attachment URL) → `ffmpeg` extract frames → zoom on the line; the **downward corner at the right end** revealed it was a rectangular *outline* (focus box), not a strikethrough/selection. Then in the live dev app (`mcp__claude-in-chrome__javascript_tool`): `el.focus(); el.matches(':focus-visible'); getComputedStyle(el).outlineStyle` → returned `true` / `auto`, confirming the element + mechanism. See [[annotator-reader-fixes]] [[layout-ui-fixes]].
@@ -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,38 +0,0 @@
---
name: table-dark-mode-tint-4419
description: "Dark-mode table tint must stay gated on overrideColor; blanket `table *` color-mix breaks plain tables AND vertical-TOC spacer cells"
metadata:
node_type: memory
type: project
originSessionId: 114eeb22-c9b6-480d-8305-3a3190855638
---
`getColorStyles` in `src/utils/style.ts` has a rule:
`blockquote, table * { background-color: color-mix(in srgb, ${bg} 80%, #000) }` in dark mode.
The `table *` part **must** be gated on `overrideColor``${isDarkMode && overrideColor ? ...}`.
**This gate has regressed twice.** #2377 (PR #2379) first added it ("avoid overriding
table background by default"). #4055 (commit `cead0f42e`, closes #4028/#4029) **removed**
the gate to darken illegible white zebra-stripe rows — which re-broke it and caused #4419:
*every* dark-mode table (and its cells) gets a tint a few shades off the page bg, even
tables with no background of their own.
Why the gate is safe now: #4392 (`176b950c9`) added light-background rewriters that map
only actually-light backgrounds → page `bg` in dark mode, regardless of overrideColor —
`getDarkModeLightBackgroundOverrides` (inline styles) + the `transformStylesheet` dark-mode
block (stylesheet rules, `isLightCssColor` luminance > 0.85). So #4028's white zebra rows
stay legible without the blanket tint. The blockquote-only tint (standalone `blockquote {}`
rule, from #2538) stays unconditional in dark mode — that's intended.
**Symptom #2 shares this root cause.** #4419 also reported "spacing between words changes"
on a vertical (writing-mode) 目录/TOC page. Those CJK web-novel books lay the TOC out as a
`<table>` with empty `<td>` spacer columns and `<span class="space">▉</span>` spacers
(custom "space" font; ▉ U+2589 is a **blank glyph, contours=0** — pure advance width, no
outline). The blanket `table *` rule paints a background on those spacer spans/cells,
making the invisible gaps show as tinted blocks → looks like the spacing changed. It's the
painted **background**, not text color, that reveals them — recoloring `.co0` (`color:#000`
→ fg) does nothing because the glyph has no outline. Gating the tint fixes both symptoms.
Tests live in `src/__tests__/utils/style-get-styles.test.ts` (assert the `blockquote,
table *` block has no `color-mix` when overrideColor false; keep the override-true and
blockquote-still-tinted cases). Related: [[css-style-fixes]].
@@ -1,36 +0,0 @@
---
name: tauri-menu-append-race-4389
description: "Un-awaited Tauri Menu.append() races on IPC → context menu items shuffle order randomly; fix = single Menu.new({ items })"
metadata:
node_type: memory
type: project
originSessionId: 8169b903-f66e-4c35-b90f-6b9110837588
---
#4389 — the bookshelf right-click context menu (Windows/Edge WebView2) showed the
same items but in a **randomly changing order** on every open.
**Root cause:** `Menu.append()` from `@tauri-apps/api/menu` returns `Promise<void>`
— each call is an async IPC round-trip to the Rust backend. `BookshelfItem.tsx`
fired ~11 `menu.append(item)` calls **without awaiting** (then `menu.popup()` also
un-awaited). The concurrent IPC requests resolve in non-deterministic order on the
Rust side, so items land shuffled. Synchronous JS call order is correct — the race
is purely in async resolution, which is why it's invisible in jsdom and only shows
on the native app.
**Fix:** build the items array in order and create the menu in ONE call:
`const menu = await Menu.new({ items })` then `await menu.popup()`.
`MenuOptions.items` / `append()` accept plain `MenuItemOptions` (`{ text, action }`)
arrays — no need to pre-create `MenuItem.new()` per item at all. Applied to both
book and group handlers.
**Testability:** extracted the order/inclusion logic into a pure
`getBookContextMenuItemIds(book): BookContextMenuItemId[]` in
`src/app/library/utils/libraryUtils.ts` (dep-light, already test-covered) so the
deterministic ordering is unit-testable without mounting the component or mocking
Tauri. Test: `src/__tests__/app/library/book-context-menu.test.ts`. The pure fn
guards the order contract; the `Menu.new({ items })` wiring is what kills the race.
**General lesson:** any un-awaited sequence of Tauri/IPC mutations that must stay
ordered (append, insert, etc.) is a latent race — batch into one call or await each.
See [[bug-patterns]].
@@ -1,29 +0,0 @@
---
name: tauri-parser-parity-tests
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
metadata:
node_type: memory
type: reference
originSessionId: 90d14df3-59ce-438f-ae91-b72e9d2b6f99
---
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
- `pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified``published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
@@ -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,37 +0,0 @@
---
name: txt-author-recognition-4390
metadata:
node_type: memory
type: project
originSessionId: f151827f-0bf2-4307-ac92-e6077df54d19
---
Issue #4390: imported Chinese web-novels showed author either **missing (未知)** or
as an **irrelevant metadata blob** (e.g. `2024/08/01发表于:是否首发:是字数1023150字116:01`).
**Key debugging tell:** the displayed *title* was the **entire filename** including
`作者:X` (e.g. `【月如无恨月长圆】(1-154)作者:陈西`). A real EPUB's `dc:title` would
be just the book name. Full-filename title ⇒ the file went through Readest's
**TXT→EPUB converter** (`bookService.ts` `/\.txt$/``TxtToEpubConverter`), which
sets `bookTitle = base filename` in the no-`《》` branch of `extractTxtFilenameMetadata`.
So "EPUB format" books can still be TXT-origin — check `src/utils/txt.ts`, not foliate-js,
when title looks like a filename. (Native Rust EPUB parser #4369 was NOT shipped in 0.11.2.)
Two root causes in `src/utils/txt.ts`:
1. `extractTxtFilenameMetadata` only pulled an author from `《》`-wrapped names; `【】`
names (the web-novel norm) got no filename author.
2. Greedy header capture `/[【\[]?作者[】\]]?[:\s]\s*(.+)\r?\n/` grabbed a whole noisy
metadata line as the author.
Fix:
- `parseLabeledAuthor(base)` extracts the labeled `作者:X` from ANY filename; title stays
the full name. Only the *labeled* form is safe on a full filename — a bracketed/bare
fallback would mistake a leading `【title】` for the author.
- `isPlausibleAuthorName()` rejects a header match that looks like a blob (contains `:`/``,
a `\d{4,}` run, or length > 20) → falls back to the filename author. Applied in BOTH
`convertSmallFile` and `extractAuthorAndLanguage` (large-file path).
Note: the `著` form is handled for file *content* headers but NOT for filenames (kept minimal).
Reported files were 455 kB / 1.25 MB → both under the 8 MB `LARGE_TXT_THRESHOLD_BYTES`
(`convertSmallFile` path). Tests live in `txt-converter.test.ts` (faithful repro of both
reported strings). Related: [[bug-patterns]].
@@ -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)
@@ -1,18 +0,0 @@
---
name: window-state-sanitize-4398
description: Windows launch crash (WebView2 0x80070057) from invalid .window-state.json; defensive sanitizer plugin
metadata:
node_type: memory
type: project
originSessionId: 40a168fd-c90e-4f00-9fbc-7effe7c0c45f
---
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
Non-obvious facts:
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
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.
-9
View File
@@ -1,12 +1,3 @@
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="
-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.”

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