Compare commits

..

1 Commits

Author SHA1 Message Date
chrox 02d005dae7 Update github workflow of release 2024-12-01 09:36:29 +01:00
2080 changed files with 16838 additions and 415374 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
-131
View File
@@ -1,131 +0,0 @@
name: Android E2E (CDP)
# On-device end-to-end tests: boots an x86_64 Android emulator (KVM), installs
# a debug APK, and runs the CDP-driven selection lane (pnpm test:android).
# Not PR-blocking: runs nightly, on demand, or when a PR is labeled
# `e2e-android`.
on:
workflow_dispatch:
schedule:
- cron: '30 19 * * *'
pull_request:
types: [labeled, synchronize]
concurrency:
group: android-e2e-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
android-e2e:
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e-android')
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: initialize git submodules
run: git submodule update --init --recursive
- name: enable KVM for the emulator
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: setup Java
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5
with:
distribution: 'zulu'
java-version: '17'
- name: setup Android SDK
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
- name: install NDK
run: sdkmanager "ndk;28.2.13676358"
- name: install dependencies
run: pnpm install --frozen-lockfile --prefer-offline
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: x86_64-linux-android
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/readest-app/src-tauri
- name: create .env.local file for Next.js
run: |
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: build debug APK (x86_64)
env:
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
run: |
cd apps/readest-app
# Only the customized files of gen/android are tracked — regenerate
# the gradle scaffolding, then restore the tracked customizations
# (same flow as the release workflow).
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
# Debug build: signed with the debug keystore, no release secrets
# needed (gradle only loads keystore.properties when it exists).
pnpm tauri android build --debug --target x86_64
APK=$(find src-tauri/gen/android/app/build/outputs/apk -name '*-debug.apk' | head -n 1)
echo "APK=$PWD/$APK" >> "$GITHUB_ENV"
test -n "$APK"
- name: cache AVD snapshot
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-api-34
- name: create AVD snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
with:
api-level: 34
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: false
script: echo "AVD snapshot created"
- name: run Android e2e lane
uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2
with:
api-level: 34
arch: x86_64
target: google_apis
force-avd-creation: false
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
disable-animations: true
script: |
adb install -r "$APK"
cd apps/readest-app && pnpm test:android
-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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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"
-468
View File
@@ -1,468 +0,0 @@
# Nightly builds. Mirrors the build matrix and build/signing steps of
# release.yml — keep cert/NDK/toolchain bumps, secret names, and the
# truly-portable AppImage + portable-Windows steps in sync between the two.
#
# Differences from release.yml: this workflow (1) stamps a nightly version
# `<base>-<YYYYMMDDHH>` (Asia/Shanghai), (2) publishes to Cloudflare R2 only (no
# GitHub release), and (3) assembles `nightly/latest.json` race-free from
# per-leg manifest fragments so a single failing leg never clobbers the manifest.
name: Nightly Readest
on:
schedule:
- cron: '0 22 * * *' # 22:00 UTC = 06:00 GMT+8
workflow_dispatch:
permissions:
contents: read
# Serialize runs so an older run can't publish nightly/latest.json after a newer
# one (no cancel — let an in-flight build finish rather than drop artifacts).
concurrency:
group: nightly-readest
cancel-in-progress: false
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
nightly_version: ${{ steps.v.outputs.nightly_version }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: main
persist-credentials: false
- id: v
run: |
BASE=$(node -p "require('./apps/readest-app/package.json').version")
STAMP=$(TZ=Asia/Shanghai date +%Y%m%d%H)
echo "nightly_version=${BASE}-${STAMP}" >> "$GITHUB_OUTPUT"
build:
needs: compute-version
strategy:
fail-fast: false
matrix:
config:
- os: ubuntu-latest
release: android
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
- os: ubuntu-22.04
release: linux
arch: x86_64
rust_target: x86_64-unknown-linux-gnu
- os: ubuntu-22.04-arm
release: linux
arch: aarch64
rust_target: aarch64-unknown-linux-gnu
- os: macos-latest
release: macos
arch: aarch64
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
args: '--target universal-apple-darwin'
- os: windows-latest
release: windows
arch: x86_64
rust_target: x86_64-pc-windows-msvc
args: '--target x86_64-pc-windows-msvc --bundles nsis'
- 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
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
ref: main
persist-credentials: false
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6
- name: setup node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: install Rust stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: nightly-${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
- name: install dependencies (ubuntu only)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android'
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: 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_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: install rclone
shell: bash
run: |
if [ "$RUNNER_OS" = "Linux" ]; then
sudo apt-get update && sudo apt-get install -y rclone
elif [ "$RUNNER_OS" = "macOS" ]; then
brew install rclone
else
choco install rclone -y
fi
- name: configure rclone
shell: bash
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
# ──────────────────────────── ANDROID ────────────────────────────
# `pnpm tauri android init` + `git checkout .` reverts tracked files
# (including package.json), so the nightly version MUST be patched AFTER
# the checkout. Mirrors release.yml's android build/signing steps.
- name: build and sign Android apks
if: matrix.config.release == 'android'
shell: bash
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 }}
run: |
version="${{ needs.compute-version.outputs.nightly_version }}"
cd apps/readest-app/
rm -rf src-tauri/gen/android
pnpm tauri android init
pnpm tauri icon ../../data/icons/readest-book.png
git checkout .
# Patch the nightly version AFTER checkout so the stamp survives.
node -e "const f='package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
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
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
universal_apk=Readest_${version}_universal.apk
arm64_apk=Readest_${version}_arm64.apk
pnpm tauri android build
cp ${apk_path}/app-universal-release.apk $universal_apk
pnpm tauri android build -t aarch64
cp ${apk_path}/app-universal-release.apk $arm64_apk
pnpm tauri signer sign $universal_apk
pnpm tauri signer sign $arm64_apk
# ──────────────────────────── DESKTOP ────────────────────────────
# Linux uses the truly-portable AppImage tauri CLI fork (mirrors
# release.yml). The nightly version is patched BEFORE `tauri build` so the
# bundle filenames carry the stamp.
- 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
- name: build desktop bundles
if: matrix.config.release != 'android'
shell: bash
env:
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 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
NODE_OPTIONS: '--max-old-space-size=8192'
run: |
version="${{ needs.compute-version.outputs.nightly_version }}"
node -e "const f='apps/readest-app/package.json';const j=require('./'+f);j.version='${version}';require('fs').writeFileSync(f, JSON.stringify(j,null,2)+'\n')"
cd apps/readest-app
# On Linux use the cargo `tauri` CLI (the truly-portable AppImage fork
# installed above); elsewhere the npm @tauri-apps/cli.
if [ "${{ matrix.config.release }}" = "linux" ]; then
cargo tauri build ${{ matrix.config.args }}
else
pnpm tauri build ${{ matrix.config.args }}
fi
# Portable Windows build: rebuild with NEXT_PUBLIC_PORTABLE_APP=true and
# ship the raw exe (mirrors release.yml). Runs after the NSIS build above.
- name: build and sign portable binaries (Windows only)
if: matrix.config.os == 'windows-latest'
shell: bash
env:
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 }}
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
arch="${{ matrix.config.arch }}"
rust_target="${{ matrix.config.rust_target }}"
# The clean NSIS build above produced bundle/nsis/Readest_<ver>_<x64|
# arm64>-setup.exe (+ .sig). The portable rebuild below runs `tauri
# build ... --bundles nsis` AGAIN with NEXT_PUBLIC_PORTABLE_APP=true,
# which OVERWRITES that installer with a portable-flavored one. Stage
# the clean installer + its updater .sig to a safe dir FIRST so the
# collect step can read the untouched copy for the windows-* keys.
if [ "$arch" = "x86_64" ]; then
nsis_name="Readest_${version}_x64-setup.exe"
else
nsis_name="Readest_${version}_arm64-setup.exe"
fi
nsis_src="target/${rust_target}/release/bundle/nsis/${nsis_name}"
mkdir -p nsis-staged
cp "$nsis_src" "nsis-staged/${nsis_name}"
cp "${nsis_src}.sig" "nsis-staged/${nsis_name}.sig"
pushd apps/readest-app/
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
pnpm tauri build ${{ matrix.config.args }}
popd
if [ "$arch" = "x86_64" ]; then
bin_file="Readest_${version}_x64-portable.exe"
else
bin_file="Readest_${version}_arm64-portable.exe"
fi
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
# Browsers on Windows refuse to download zips containing exe files, so
# ship the exe directly (matches release.yml).
cp "$exe_file" "$bin_file"
pushd apps/readest-app/
pnpm tauri signer sign "../../$bin_file"
popd
# ───────────────── COLLECT ARTIFACTS + BUILD FRAGMENT ─────────────────
# Each leg copies its updater artifacts (+ .sig) into ./nightly-out and
# emits a per-leg manifest fragment keyed by the EXACT Tauri platform keys
# the client expects (see src/helpers/updater.ts::getNightlyPlatformKey and
# src/components/UpdaterWindow.tsx::TAURI_UPDATER_KEYS). The fragment
# `signature` is the .sig file CONTENTS; `url` is the download.readest.com
# URL of the uploaded artifact under nightly/<version>/.
- name: collect artifacts + build manifest fragment
shell: bash
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
release="${{ matrix.config.release }}"
arch="${{ matrix.config.arch }}"
rust_target="${{ matrix.config.rust_target }}"
base_url="https://download.readest.com/nightly/${version}"
out="$PWD/nightly-out"
frag_dir="$out/frag"
mkdir -p "$out" "$frag_dir"
# Cargo WORKSPACE: bundles land in the REPO-ROOT target/ dir, not
# apps/readest-app/src-tauri/target/ (matches release.yml line 371).
bundle="target"
# Stage one artifact + its .sig into $out, then append a
# platforms[<key>] = {signature, url} entry to the fragment JSON.
frag="$frag_dir/${release}-${arch:-all}.json"
echo '{"platforms":{}}' > "$frag"
add_entry() {
local src="$1"; local fname="$2"; local key="$3"
if [ ! -f "$src" ] || [ ! -f "${src}.sig" ]; then
echo "::error::missing artifact or signature for $key: $src"
exit 1
fi
cp "$src" "$out/$fname"
cp "${src}.sig" "$out/${fname}.sig"
local sig; sig=$(cat "${src}.sig")
local url="${base_url}/${fname}"
jq --arg k "$key" --arg sig "$sig" --arg url "$url" \
'.platforms[$k] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
echo "fragment += $key -> $fname"
}
case "$release" in
android)
# Signed in the android step; basenames already version-stamped.
add_entry "apps/readest-app/Readest_${version}_universal.apk" "Readest_${version}_universal.apk" "android-universal"
add_entry "apps/readest-app/Readest_${version}_arm64.apk" "Readest_${version}_arm64.apk" "android-arm64"
;;
macos)
# Universal updater bundle: bundle/macos/Readest.app.tar.gz (no
# version/arch on disk). Upload under the stable convention name
# Readest_universal.app.tar.gz; both darwin keys point at it.
src="${bundle}/universal-apple-darwin/release/bundle/macos/Readest.app.tar.gz"
add_entry "$src" "Readest_universal.app.tar.gz" "darwin-aarch64"
# Reuse the already-staged copy for the x86_64 key (same artifact).
x86_sig=$(cat "$out/Readest_universal.app.tar.gz.sig")
jq --arg sig "$x86_sig" --arg url "${base_url}/Readest_universal.app.tar.gz" \
'.platforms["darwin-x86_64"] = {signature: $sig, url: $url}' "$frag" > "$frag.tmp" && mv "$frag.tmp" "$frag"
echo "fragment += darwin-x86_64 -> Readest_universal.app.tar.gz"
;;
windows)
if [ "$arch" = "x86_64" ]; then
nsis_name="Readest_${version}_x64-setup.exe"
portable_name="Readest_${version}_x64-portable.exe"
nsis_key="windows-x86_64"; portable_key="windows-x86_64-portable"
else
nsis_name="Readest_${version}_arm64-setup.exe"
portable_name="Readest_${version}_arm64-portable.exe"
nsis_key="windows-aarch64"; portable_key="windows-aarch64-portable"
fi
# Read the CLEAN NSIS installer staged before the portable rebuild
# (the rebuild overwrites bundle/nsis/...-setup.exe). See the
# "build and sign portable binaries" step.
add_entry "nsis-staged/${nsis_name}" "$nsis_name" "$nsis_key"
# Portable exe was copied + signed into the repo root above.
add_entry "$portable_name" "$portable_name" "$portable_key"
;;
linux)
# Truly-portable AppImage: bundle/appimage/Readest_<ver>_<amd64|aarch64>.AppImage
if [ "$arch" = "x86_64" ]; then
appimage_name="Readest_${version}_amd64.AppImage"
key="linux-x86_64-appimage"
else
appimage_name="Readest_${version}_aarch64.AppImage"
key="linux-aarch64-appimage"
fi
# The Linux leg builds with `cargo tauri build` WITHOUT `--target`
# (no matrix args), so cargo emits bundles under target/release/
# (host-target default) — NOT target/<triple>/release/ like the
# macOS/Windows legs, which DO pass `--target`. So there is no
# ${rust_target} subdir here.
add_entry "${bundle}/release/bundle/appimage/${appimage_name}" "$appimage_name" "$key"
;;
*)
echo "::error::unknown release leg: $release"; exit 1
;;
esac
- name: upload artifacts + fragment to R2
shell: bash
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
base="r2:readest-releases/nightly/${version}"
out="$PWD/nightly-out"
# Artifacts (exclude the local frag/ scratch dir).
rclone copy "$out" "$base/" --exclude "frag/**"
# Per-leg manifest fragment.
rclone copy "$out/frag" "$base/manifest-fragments/"
assemble-manifest:
needs: [compute-version, build]
if: ${{ always() && needs.build.result != 'cancelled' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: install rclone + jq
run: sudo apt-get update && sudo apt-get install -y rclone jq
- name: configure rclone
run: |
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[r2]
type = s3
provider = Cloudflare
access_key_id = ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
secret_access_key = ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
endpoint = https://${{ secrets.RELEASE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com
EOF
- name: assemble + atomically promote latest.json
run: |
set -euo pipefail
version="${{ needs.compute-version.outputs.nightly_version }}"
base="r2:readest-releases/nightly"
# Pull only the fragments that SUCCEEDED legs uploaded.
rclone copy "$base/${version}/manifest-fragments" ./frag || true
if [ -z "$(ls -A ./frag 2>/dev/null)" ]; then
echo "::error::no manifest fragments found — all build legs failed; manifest NOT promoted"
exit 1
fi
# Merge every fragment's .platforms into one manifest.
jq -s \
--arg version "$version" \
'{version: $version, pub_date: (now | todateiso8601), notes: "Nightly build", platforms: (map(.platforms) | add)}' \
./frag/*.json > latest.json
echo "Assembled latest.json:"
jq '{version, platforms: (.platforms | keys)}' latest.json
# Promote the manifest with a directory `rclone copy`, exactly like the
# stable release flow writes releases/latest.json (upload-to-r2.yml).
# A single-file `rclone copyto`/`moveto` first issues a CreateBucket
# probe (PUT /<bucket>) that the object-scoped R2 token can't satisfy
# (403 AccessDenied); a directory copy PUTs the object directly. R2
# PutObject is atomic, so readers never observe a half-written manifest.
mkdir -p promote && cp latest.json promote/latest.json
rclone copy promote "$base/"
- name: prune old nightly folders (keep newest 7)
run: |
set -euo pipefail
base="r2:readest-releases/nightly"
# Version dirs are <base>-<YYYYMMDDHH>. Lexicographic order is NOT
# chronological across a base-version bump (e.g. 0.2.0-* sorts after
# 0.11.0-*), so sort by the numeric stamp tail (everything after the
# last '-') to keep the newest 7 by build time.
mapfile -t dirs < <(rclone lsf "$base/" --dirs-only | sed 's:/$::' \
| sed -E 's/^(.*)-([0-9]{10})$/\2 \1-\2/' | sort -n -k1,1 | cut -d' ' -f2-)
count=${#dirs[@]}
if [ "$count" -gt 7 ]; then
for d in "${dirs[@]:0:$((count-7))}"; do
echo "pruning $d"
rclone purge "$base/$d"
done
fi
- name: notify on failure
if: failure()
run: echo "::error::Nightly assemble failed — manifest not promoted."
-321
View File
@@ -1,321 +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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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
- name: Unit tests
working-directory: apps/readest-app/src-tauri
run: cargo test -p Readest --lib
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # 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: run format check
run: |
pnpm format:check || (pnpm format && git diff && exit 1)
# pnpm lint here is web-only (tsgo + biome). The koplugin syntax check
# (lint:lua) runs in the test_extensions job, which installs LuaJIT only
# when the koplugin sources changed.
- 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
# The jsdom unit suite is the slowest part of the PR checks, so it is split
# across two parallel shards (vitest --shard). The browser tests need
# Playwright and run only on shard 1; koplugin + browser-extension tests
# moved to the test_extensions job.
test_web_app:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # 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
# Playwright is only needed by the browser tests, which run on shard 1.
- name: cache playwright browsers
if: matrix.shard == 1
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
if: matrix.shard == 1
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 unit tests (shard ${{ matrix.shard }}/2)
working-directory: apps/readest-app
run: pnpm test:pr:web:unit --shard=${{ matrix.shard }}/2
- name: run web browser tests
if: matrix.shard == 1
working-directory: apps/readest-app
run: pnpm test:browser
# Browser-extension tests + build always run. The koplugin lint + Lua tests
# (and the ~45s LuaJIT/busted install they need) only run when the koplugin
# sources changed, so most PRs skip that cost entirely.
test_extensions:
runs-on: ubuntu-latest
# pull-requests: read lets dorny/paths-filter list a PR's changed files
# via the REST API (the top-level grant is contents: read only).
permissions:
contents: read
pull-requests: read
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: 'true'
- name: detect koplugin changes
id: changes
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
with:
filters: |
koplugin:
- 'apps/readest.koplugin/**'
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # 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: run extension tests
working-directory: apps/readest-app
run: pnpm test:extension
- name: build browser extension
working-directory: apps/readest-app
run: pnpm build-browser-ext
- name: cache apt packages
if: steps.changes.outputs.koplugin == 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-test-koplugin-${{ runner.os }}
- name: install LuaJIT + busted (for koplugin lint + tests)
if: steps.changes.outputs.koplugin == 'true'
run: |
sudo apt-get update
# luajit — pnpm lint:lua + 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: lint koplugin
if: steps.changes.outputs.koplugin == 'true'
working-directory: apps/readest-app
run: pnpm lint:lua
- name: run koplugin tests
if: steps.changes.outputs.koplugin == 'true'
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # 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
# Disable this action's built-in rust-cache so the explicit
# Swatinem/rust-cache below is the single cache (it's the one
# configured with cache-workspace-crates for the vendored tauri fork).
cache: false
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
# cache-workspace-crates caches the path/workspace crates too: the
# vendored tauri fork (packages/tauri, packages/tauri-plugins, wired
# via [patch.crates-io]) and the local src-tauri/plugins/*. These are
# workspace members that rust-cache prunes by default, so the whole
# tauri stack — plus every crates.io plugin that depends on the
# patched `tauri` — rebuilt on every run. They change only
# sporadically (pinned submodules), so caching them is a big win.
# The key is bumped (-ws) so the old workspace-crate-less cache is
# invalidated and the next run repopulates it with the workspace
# crates included.
key: tauri-cargo-ws
cache-all-crates: 'true'
cache-workspace-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
+58 -360
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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,67 @@ 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- uses: actions/checkout@v3
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # 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@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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'
if: matrix.config.os == 'ubuntu-latest'
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
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: install dependencies (ubuntu only - armhf specific)
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
- name: Create .env.local file for Next.js
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
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ vars.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ vars.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ vars.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
- 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_APP_PLATFORM=tauri" >> .env.local
cp .env.local apps/readest-app/.env.local
- name: Copy .env.local to apps/readest-app
run: cp .env.local apps/readest-app
- name: build and upload Android apks
if: matrix.config.release == 'android'
- uses: tauri-apps/tauri-action@v0
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 }}
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 .
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'
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 +108,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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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
-10994
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" }
-71
View File
@@ -1,71 +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
# Opt into the self-contained `.next/standalone` tree for this image only;
# next.config.mjs gates `output: 'standalone'` on BUILD_STANDALONE so other
# web builds keep their default output.
ENV BUILD_STANDALONE=true
RUN pnpm build-web
# Production runtime ships only the standalone server, its traced node_modules,
# and the static/public assets — no pnpm, no source tree, no dev dependencies,
# no build cache. `output: 'standalone'` (next.config.mjs) emits the self-contained
# tree under .next/standalone, so the entrypoint is a plain `node server.js`.
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
WORKDIR /app
# Monorepo-rooted standalone tree: server.js + hoisted, traced node_modules.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/standalone ./
# Static and public assets are not part of the standalone trace; copy them next
# to the server so their default relative paths resolve.
COPY --from=build --chown=node:node /app/apps/readest-app/.next/static ./apps/readest-app/.next/static
COPY --from=build --chown=node:node /app/apps/readest-app/public ./apps/readest-app/public
USER node
EXPOSE 3000
ENTRYPOINT ["node", "apps/readest-app/server.js"]
+21 -302
View File
@@ -1,137 +1,27 @@
<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%" />
</a>
<h1>Readest</h1>
<br>
# Readest
[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.
[![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]
[![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>
<p align="center">
<a href="#features">Features</a> •
<a href="#planned-features">Planned Features</a> •
<a href="#screenshots">Screenshots</a> •
<a href="#downloads">Downloads</a> •
<a href="#documentation">Documentation</a> •
<a href="#getting-started">Getting Started</a> •
<a href="#troubleshooting">Troubleshooting</a> •
<a href="#support">Support</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%" />
</a>
</div>
Readest is an open-source ebook reading software designed for immersive and deep reading experiences. It supports EPUB and PDF document formats, and with Tauri v2, Readest is cross-platform, running seamlessly on macOS, Windows, and Linux.
## Features
<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. | ✅ |
## 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. | 🔄 |
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
- **EPUB and PDF Support**: Enjoy both EPUB and PDF formats, making Readest versatile for all your reading needs.
- **Cross-Platform Compatibility**: Runs on macOS, Windows, and Linux with Tauri v2.
- **Immersive Reading Experience**: Supports advanced reading features like note-taking, highlighting, and progress syncing.
- **Optimized Performance**: Readest is lightweight, ensuring smooth performance even with large files.
- **Customizable Interface**: Built with daisyUI for a modern and user-friendly UI.
## Screenshots
![Annotations](./data/screenshots/annotations.png)
![TTS](./data/screenshots/tts_speak_aloud.png)
![DeepL](./data/screenshots/deepl.png)
![Footnote](./data/screenshots/footnote_popover.png)
![Wikipedia](./data/screenshots/wikipedia_vertical.png)
![Theming Dark Mode](./data/screenshots/theming_dark_mode.png)
---
## Downloads
### Mobile Apps
<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].
## Documentation
Guides, tutorials, and FAQs for installing and using Readest live in the official documentation:
📖 **[https://readest.com/docs][link-docs]**
## 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.
For the best experience, 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
npm install -g pnpm
nvm install v22
nvm use v22
rustup update
```
@@ -142,18 +32,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
npm install -g pnpm
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
@@ -168,197 +58,26 @@ This command will display information about the installed Tauri dependencies and
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
### 4. Build the 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.
## Contributors
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
<a href="https://github.com/readest/readest/graphs/contributors">
<p align="left">
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
</p>
</a>
## Support
If Readest has been useful to you, consider supporting its development at [donate.readest.com](https://donate.readest.com), where you'll find all available donation methods, including GitHub Sponsors, card payments, and crypto. Your contribution helps us fix bugs faster, improve performance, and keep building great features.
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.
## 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.
This project is licensed under the AGPL V3 License. See the 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/badge/license-AGPL--3.0-teal
[badge-release]: https://img.shields.io/github/v/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-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-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-docs]: https://readest.com/docs
[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-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/
Happy reading with 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
-150
View File
@@ -1,150 +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
## Security
- [download_file scope Android regression](download-file-scope-android-regression.md) — #4639 strict `is_allowed` broke ALL Android downloads to app data dir (covers/dicts/books); `app.fs_scope()` lacks command-scoped capability globs; fix = `app.path()` base-dir membership. On-device CDP verify recipe + raw-invoke Channel trick
- [Security advisories 2026-06](security-advisories-web-2026-06.md) — all 4 GHSA fixed in PR #4638 (web: A OPDS-proxy SSRF + canonical `isBlockedHost` in network.ts + `isLanAddress` merge, B storage `isSafeObjectKeyName`, D Stripe `metadata.userId` ownership) + PR #4639 (native C: `transfer_file.rs` fs_scope guard). OPDS proxy can't require auth (`<img>` usage); strict `is_allowed` for C; shared-target worktree build-cache pollution gotcha
## 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
- [TOC current-position row](toc-current-position-row.md) — synthetic "Current position" row (open-book icon + live `progress.page`) injected one level deeper under the active TOC item via `buildTOCDisplayItems` in `TOCItem.tsx`. INVARIANT: insert AFTER the active item so its `flatItems` index stays valid for the auto-scroll effects
- [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
- [Dark-mode texture occluded by body bg (#4446)](dark-mode-texture-body-bg-4446.md) — RESOLVED (PR #4564): `body.theme-dark{bg !important}` from #4392 (v0.11.4, NOT foliate-js) painted iframe bodies opaque dark → occluded host texture + poisoned `docBackground` capture → opaque segments/view bgs; fix = `transparent !important` UNCONDITIONALLY (texture-gating would go stale: capture is once-per-section-load); CDP gotchas = patch ALL multiview iframes, stale preload views survive navigation ±2, load-listener sees exact capture-time state
- [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
- [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected
## 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
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=requiredoptional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed
- [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe
## Testing
- [Nightly updater Android E2E](nightly-updater-android-e2e.md) — real Xiaomi/HyperOS test of #4577 self-updater; `pnpm dev-android` (--features devtools) for CDP, raw-socket CDP discovery, nightly>stable comparator, MIUI 单次安装授权 install gates
- [Android CDP e2e lane](android-cdp-e2e-lane.md) — `pnpm test:android`: adb+CDP drives the installed app on device/emulator; discover-don't-assume targeting, injected hyphenation, MediaStore VIEW transient open (canonical `_data` path gotcha), per-section frame restore; CI workflow with KVM emulator + debug x86_64 APK (no signing secrets)
- [CDP Android WebView profiling](cdp-android-webview-profiling.md) — drive the on-device Readest WebView via adb+CDP to run JS probes/benchmarks inside the live app (no rebuild); gotchas: locked device freezes fetch (not invoke), visible:false throttles setTimeout, `__TAURI_INTERNALS__.convertFileSrc/invoke` always present, books in internal `/data/user/0/...`, fs `read{rid,len}` last-8-bytes=nread, `fs|close` not ACL-allowed, curl mishandles WebView HTTP framing
- [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
- [TTS browser e2e harness](tts-browser-e2e-harness.md) — faithful auto-advance test (real `<foliate-view>` + real `useTTSControl` + mock ONLY the 3 client modules; mock `speak()` yields `end` to drive the real `forward()` walk); seed readerStore/bookDataStore + `settings.globalViewSettings` (else `getMergedRules` crash stops TTS); reproduce FoliateViewer relocate→setProgress; sample-alice Ch4=section 6/Ch5=section 7; assert badge `false` BEFORE tts-stop
- [TTS sync chrome verification](tts-sync-chrome-verification.md) — Edge TTS WORKS in claude-in-chrome (WebSpeech errors there with `InvalidStateError`); use an Edge voice to verify TTS-driven features live (RSVP followed at ~171 wpm). Synthetic-CFI debug recipe (expose controller, `syncToCfi(view.getCFI(docIndex, word.range))`). Exposed the #3235 cross-realm `instanceof Range` bug (frozen RSVP/paragraph follow) → `isRangeLike()` duck-type fix
- [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions`
## Build & Vendoring
- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..`
- [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent)
- [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9)
- [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow
- [Deploy workers.dev SNI-block + proxy](deploy-workers-dev-sni-proxy.md) — pnpm deploy crash (CN): workers.dev SNI-blocked (DoH useless), R2 populate WS hangs even via proxy; shipped fix = `dangerous.disableIncrementalCache:true` in open-next.config (stock deploy skips populate; readest has no ISR so runtime no-op)
- [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
- [Android hyphen selection bounds (#1553)](android-hyphen-selection-bounds-1553.md) — Blink paints the start handle on the paragraph's LAST hyphen when a touch selection starts at the first word of a hyphenated paragraph (`ComputePaintingSelectionStateForCursor` lacks the generated-text offset remap, hyphen offsets {0,1}); drag-extend re-anchors base there. Fix = repair jumped anchor + suppress handles (empty-commit needs one painted frame) + `SelectionRangeEditor` custom handles; multicol NOT required; desktop/iOS unaffected
- [Android NativeFile vs RemoteFile I/O](android-nativefile-remotefile-io.md) — why NativeFile is slow (4-IPC/chunk + bridge serialization, tauri#9190); RemoteFile CANNOT replace it on Android (asset-protocol Range broken: start>0 → "Failed to fetch", start-0 capped at 1,024,000; plain no-Range fetch returns full file at 281 MB/s); measured 44/100/281 MB/s; speedups = handle-reuse (2.3×), whole-file asset loader (6.3×), or fix wry upstream. Verified live via CDP.
- [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
- [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent``shared-intent``useAppUrlIngress``useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant)
- [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT``LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx`
- [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified
## Feature Notes
- [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork)
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — fingerprint/Face ID startup unlock layered over PIN (mobile); gate must read flag from `appLockStore` not un-seeded `settingsStore` (race); `tauri-plugin-biometric` is `#![cfg(mobile)]` (desktop clippy skips it; pin in root Cargo.lock); scope i18n manually (en unscanned, full extract churns drift)
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — single-tap opens gallery/table-zoom in **reflowable** EPUBs (long-press unchanged); `iframe-long-press` message renamed to `iframe-open-media`, hook `useLongPressEvent``useOpenMediaEvent`; shared `detectMediaTarget`; `handleClick` got `isFixedLayout`
- [#4584 tap-death investigation](issue-4584-tap-death-investigation.md) — UNFIXED; `isPopuped` self-heals (RED HERRING, don't "fix" it); likely WebView-148-specific (emulator=133 can't repro); Android emulator/CDP gesture-verification gotchas (swiftshader ANR=artifact, CDP can't native-select, screenX=0)
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected selections (`ran`/`mice`/`analyses`) resolve to base headwords (`run`/`mouse`/`analysis`) in dicts that store only lemmas (ODE). Pluggable `lemmatize/` registry (default English, explicit non-English no-op), English rules+irregulars, appended to tail of `buildLookupCandidates` so exact match wins; over-generate + dict-validates; `-ses→-sis` ordered before `-es`
- [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `<ruby cfi-skip>…<rt cfi-inert>` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK)
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict
- [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `<path>` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header)
- [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate)
- [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after `</feed>`; Firefox DOMParser → `<parsererror>` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap)
- [OPDS 2.0 JSON search greyed out (#4502)](opds2-json-search-4502.md) — `isSearchLink` ignored templated `application/opds+json` links → `hasSearch` false → disabled navbar input; add `MIME.OPDS2`+`templated`, `expandOPDSSearchTemplate` (foliate `uri-template.js`), handleSearch OPDS2 branch. Gotcha: `resolveURL` mangles `{?query}` braces — expand template BEFORE resolving
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — detail-view descriptions showed raw `<p>`/`&quot;` tags; aggregator double-escapes `type="text"` summary + `PublicationView` dumped it into unsanitized `dangerouslySetInnerHTML`; fix = `getOPDSDescriptionHtml` (decode-one-level-iff-fully-escaped, then `sanitizeHtml`)
- [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`
## Feedback
- [Commit messages English-only](feedback-commit-message-english-only.md) — commit messages + PR titles must be English only (no CJK glyphs, no em/en dashes); keep CJK examples/screenshots in the PR body, code, and tests. From PR #4660
## 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
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview)
- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build
- [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`<i>` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`)
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap<Document,...>` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe
- [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `<p>`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects`
- [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
- [Double-click-drag turns page (#4524)](dblclick-drag-pageturn-4524.md) — web double-click+drag selection also turned the page; 1st click's deferred single-click (250ms) fires mid-drag while 2nd-click button held; fix = `isMouseDown` flag in `iframeEventHandlers.ts` gates the deferred `postSingleClick`; synthetic-repro gotchas (shadow-DOM iframe walk, chained-repro timing pollution, reload to re-bind listeners)
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — RSVP word was hardcoded `font-mono`; now mirrors the reader font via `getBaseFontFamily(viewSettings)` (new export in `style.ts`, shares `buildFontFamilyLists` with `getFontStyles`). Overlay renders in the TOP document (portal to body) where custom + basic Google fonts are mounted; known gap = built-in CJK web fonts only in top doc when `isCJKEnv()`
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — Arabic/RTL word window showed separated, reversed letters: ORP focus-letter split slices words by char index (breaks shaping/order); fix = `isRTLText` → render RTL whole via the CJK `.rsvp-word-whole` branch with `dir=rtl`. Literal-RTL-char Edit pitfall → write regex with `\u` escapes
- [Edge TTS word-highlight drift on middle sentences](tts-word-highlight-singletextnode-drift.md) — `rangeTextExcludingInert` TEXT_NODE fast path ignored range offsets → returned whole paragraph → word offsets drift (spoken "Those"→hl "if th"); only middle sentences of single-`<span>` paras (cac=TEXT_NODE); Edge-only; fix=slice [startOffset,endOffset]; added dev-only `[TTS] word-sync` log; select-word→popup-headphone repro
- [TTS start-from-selection bugs](tts-start-from-selection.md) — foliate `from()` picked first mark at/after selection → started NEXT sentence for non-first words (fix=last mark at/before); + Annotator now `cloneRange()`+`view.deselect()` on TTS start so the word doesn't stay selected; jsdom needs `CSS.escape` polyfill (vitest.setup) since `from()` uses it
- [Reuse TTS session on Paragraph/RSVP entry](tts-reuse-session-mode-entry.md) — modes only engaged following on a fresh `playing` event → entering with TTS already playing didn't sync. Fix = `TTSController.redispatchPosition()` + `useTTSControl` `tts-sync-request` replay (position-before-state) + per-mode engage-on-entry effect (following=true, reset lastSequenceSeen, dispatch request); RSVP paused branch also `setExternallyDriven(true)`. Paragraph live-verified ("Following audio" on entry)
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — v0.11.4 regression: stray horizontal line below footnote marker. #4383 inlined custom `@font-face` BEFORE the `@namespace epub` (which lived in `getPageLayoutStyles`), invalidating it per CSS spec → namespaced `aside[epub|type~="footnote"]{display:none}` dropped → book's `aside{border:3px double}` showed. Only with custom fonts loaded. Fix = hoist `@namespace` to front of `getStyles`. Repro needs XHTML (`epub:type` namespaced only in XML); Playwright `setContent` parses HTML and won't reproduce
- [Scrolled-mode notch mask vs texture (#4486)](notch-mask-texture-4486.md) — top inset mask occluded the bg texture; full-cell + clip-path paint-box-matching for tile alignment; CDP-inject + MAE seam verification on device; adb taps in status-bar region eaten by SystemUI
- [Paragraph-mode accidental exit + off-center bar (#4474)](paragraph-mode-accidental-exit-4474.md) — backdrop/center taps exited focus mode (stray "too high/low" taps); `ParagraphBar` only reshows on mousemove (no touch reshow) so can't just delete tap-exits → new `paragraph-show-controls` event reveals the bar instead. Also bar `absolute``fixed`: it centered on the gridcell which a pinned sidebar shifts right, while the paragraph centers on the `fixed inset-0` overlay/viewport
- [Share intent + customizable toolbar (#4014)](annotation-share-toolbar-4014.md) — Share tool in the selection toolbar (sharekit gated mobile+macOS only re: #4343 Windows freeze; `canShareText`/`shareSelectedText` in dual-purpose `share.ts`) + drag-and-drop customizer sub-page; `annotationToolbarItems` view setting (Share hidden by default); pure helpers in `annotationToolbar.ts`
## 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
- [TXT chapter measure-word false positives (#4658)](txt-chapter-measure-word-4658.md) — `第一封信`/`第四本书…` (量词 prose) detected as chapters; `createChapterRegexps('zh')` unit class split into strong `[章节回讲篇话]` (attached title OK) vs weak/量词 `[卷本册部封]` (title needs a separator or line end, never a bare noun)
- [Cover stale until refresh (in-place mutation vs React.memo)](cover-stale-inplace-mutation-memo.md) — editing a book cover in details + Save left the library cover stale until reload; `handleUpdateMetadata` mutated `book` IN PLACE so memoized `<BookCover>`'s prev snapshot pointed at the same object → comparator saw no change → skip; fix = pure `getBookWithUpdatedMetadata` returns a NEW book object. Cloning in `updateBook` wouldn't help (original already mutated). Verified live on emulator via CDP fiber-store extraction (A: mutate→stale, B: new obj→updates)
- [Series/author folder back no-op (#4437)](series-folder-back-noop-4437.md) — back arrow dead inside Series/Author folder after cold start; Next.js 16.2 static-export empty-search `router.replace` no-op (same as #3782/#3832); `GroupHeader.handleBack` missed the `group=''` workaround. CDP-verify gotcha: synthetic `el.click()` won't fire React onClick — use trusted `Input.dispatchMouseEvent`
## Library Architecture
- [Book action platform surfaces](book-actions-platform-surfaces.md) — library context menu is **Tauri-desktop-only** (`hasContextMenu` false on web + iOS/Android); cross-platform book actions go in `BookDetailView`'s icon row. #4543 Goodreads search added both surfaces + a built-in web-search provider for highlighted-text lookup
## 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
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin patched min-version in `pnpm-workspace.yaml` `overrides:` (NOT package.json `pnpm.overrides`, which pnpm 9+ ignores); watch for existing too-low pins; alert#≠issue# so no `Closes #` (PR #4523)
- [CI/PR delivery + push keepalive](ci-pr-delivery-and-push.md) — package small PRs from a dirty dev tree via temp-index plumbing (no worktree); slow pre-push hook (~55s full suite) + SOCKS-proxy SSH → idle "Broken pipe", fixed with `ServerAliveInterval`; `--no-verify` safe once the hook already passed (always `git ls-remote` to confirm a push landed)
@@ -1,21 +0,0 @@
---
name: android-cdp-e2e-lane
description: "pnpm test:android — CDP+adb e2e lane driving the installed app on a device/emulator; harness design, gotchas, CI workflow"
metadata:
node_type: memory
type: project
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
---
New test tier (PR #4545, merged 2026-06-12): `pnpm test:android``scripts/test-android.sh``vitest.android.config.mts` (node env, serial, retry 1) → `src/__tests__/android/*.android.test.ts`. Helpers in `src/__tests__/android/helpers/`: `adb.ts` (tap/longPress/`motionGesture` = one-shell DOWN/MOVE/UP chain), `cdp.ts` (forward `webview_devtools_remote_<pid>`, node:http discovery with Host header, `CdpPage.evaluate` async-IIFE), `reader.ts` (fixture open + probes). Soft-skips without adb/device/app. Covers the [[android-hyphen-selection-bounds-1553]] cases: prone long-press → app handles, drag repair clamp, tap dismissal, handle-drag extension, mid-paragraph native handles, cross-page corner-dwell auto-turn.
Design principles (per chrox): discover-don't-assume (find a hyphenated on-screen paragraph at runtime, start in main text via `gotoChapter('chapter\\s*4')`), force hyphenation by injecting `p{hyphens:auto!important;text-align:justify!important}` into section docs (app settings irrelevant), poll-don't-sleep (`waitFor`), fixture `sample-alice.epub` opened TRANSIENTLY via MediaStore VIEW intent.
Gotchas:
- MediaStore `_data` is the canonical `/storage/emulated/0/...` path — query `_data LIKE '%/<basename>'`, NOT the `/sdcard/` symlink you pushed to. `content query --projection` takes ONE column or space-separated (not comma). VIEW with `--grant-read-uri-permission` works on a permissionless fresh install.
- Multi-section books: each section is its own iframe — record + restore pagination via the TARGET section's frame x (`c.index === sectionIndex`), not `contents[0]`.
- Corner auto-turn (#1354) zone is the reading area INSET by content margins — a drag point in the bottom margin is ignored by `cornerAt`; aim ~4% inside the text area.
- adb `input motionevent` 5px moves are under touch slop → no pointermove; make post-turn drag movements large.
- Verified green on Xiaomi 13 (physical) AND fresh Pixel_9_Pro AVD (`emulator -avd Pixel_9_Pro`, install the aarch64 dev APK), ~21 s.
CI: `.github/workflows/android-e2e.yml` — ubuntu-latest + KVM udev rule, debug x86_64 APK (`tauri android build --debug --target x86_64`; gradle skips keystore.properties when absent so NO signing secrets), `reactivecircus/android-emulator-runner@v2` (api 34, AVD snapshot cached), nightly + workflow_dispatch + `e2e-android` PR label; not PR-blocking. NOTE: emulator-runner not SHA-pinned yet (repo convention pins by SHA).
@@ -1,30 +0,0 @@
---
name: android-hyphen-selection-bounds-1553
description: "#1553 Android selection breaks on first word of hyphenated paragraphs — Blink generated-hyphen bounds bug, full RCA + app-side repair/suppress fix"
metadata:
node_type: memory
type: project
originSessionId: 16f94822-04b0-4be3-a47e-8a2e3cab290a
---
Issue #1553 root cause (verified live on Xiaomi 13, WebView 147, via [[cdp-android-webview-profiling]]):
**Upstream**: filed as crbug **522869957** (2026-06-12, by chrox, with full analysis + repro + screenshot). Pre-existing same-root-cause report: crbug **41496034** (Jan 2024, "&shy;" framing — why searches missed it; P2 Available, on "Rendering Core 2026 Fixit" hotlist; MS triager confirmed soft-hyphen cause in Feb 2024). Cross-link comments posted on both.
**Blink bug**`LayoutSelection::ComputePaintingSelectionStateForCursor` (third_party/blink/renderer/core/editing/layout_selection.cc) compares `paint_range_` offsets (paragraph **IFC text-content space**, via `OffsetMapping::GetTextContentOffset`) against `position.TextOffset()` — but auto/soft-hyphen fragments are **layout-generated text with self-relative offsets {0,1}**. So a touch selection starting at IFC offset 0 (first word of a paragraph) makes EVERY hyphen fragment in that paragraph report `kStart` → each records a start bound (`SelectionBoundsRecorder`, last paint wins) → **native start handle is drawn at the paragraph's LAST hyphen**. The highlight itself is correct because the sibling path `ComputeSelectionStatus(InlineCursor&)` HAS the `IsLayoutGeneratedText()` remap; the bounds path lacks it. Still unfixed upstream as of Chromium main (June 2026); seemingly unreported.
Key facts:
- Trigger: touch selection (handles visible — `ShouldRecordSelection` gates on `IsHandleVisible()`, so desktop/mouse unaffected; iOS=WebKit unaffected) + selection start at IFC offset ≤1 + generated hyphens in the same paragraph. **Multicol NOT required** (reproduced in a plain top-document div).
- Worse than cosmetic: long-press **drag-extension re-anchors the base by hit-testing the bogus bound** → observed anchor jump 0→325 (offset just before last hyphen), selection became [53,325] instead of [0,53]. Explains "select upward works" workaround (upward drags anchor on the correct end bound).
- Auto-hyphens show up as separate ~0.3em rects in `Range.getClientRects()` on hyphenated lines — usable as a generated-hyphen detector.
- **JS `removeAllRanges()+addRange()` does NOT hide already-visible touch handles synchronously** — the empty selection must commit through one painted frame (double-rAF in the iframe window) before re-adding; then handles stay hidden for all later JS selection updates.
Fix (**PR #4545, MERGED 2026-06-12**; worktree cleaned): detection utils in `src/utils/sel.ts` (`isHyphenHandleBugProneRange`, `repairJumpedSelectionRange`, `hasTrailingHyphenRectPattern`); gesture-initial anchor capture + touchend sanitize in `useTextSelector` (repair jumped anchor → suppress handles via empty-commit → `makeSelection(handlesSuppressed)`); `SelectionRangeEditor.tsx` renders custom drag handles (reuses `Handle` + extracted `buildRangeFromPoints`/`getHandlePositionsFromRange` in annotatorUtil) for suppressed selections. Gated to Android app + exact bug condition; flipping to always-custom-handles later = drop the proneness gate.
Gotcha: `input motionevent DOWN/MOVE/UP` (adb) simulates long-press-drag; `input swipe x y x y 700` simulates plain long-press.
Two post-fix races found by chrox & fixed (commits c6e9f48, 9a63157):
1. **Tap-dismiss resurrection** — an Android tap doesn't clear the selection, it COLLAPSES it to a caret at the tap point, with selectionchange ~10-20ms AFTER touchend; the tap's touchend re-entered sanitize (fallback prone-check on the still-valid old selection), the collapse raced into the double-rAF window (also MUTATING the held Range in place), and makeSelection committed a collapsed range as a suppressed selection → "empty custom handles at the tap spot". Fix = gesture gate (`if (!initial) return`) + post-rAF abort when `sel.rangeCount > 0 || finalRange.collapsed`.
2. **Extent overshoot** — the corrupted drag has TWO modes: base re-anchors at the bogus bound (anchor jump, Argentina test) OR the EXTENT lands there while the anchor stays at 0 (range then CONTAINS the initial anchor → jump-repair doesn't fire; observed +1013 chars). Fix = `rangeFromAnchorToPoint`: rebuild [gesture-initial anchor → caret at last native touch position] (`pointerPos`, reset per-gesture in handleTouchStart), fallback to jump-repair.
Verify-build gotcha: `pnpm dev-android` snapshots the Next bundle at build START — an amend after kickoff ships the PRE-amend frontend (grep of out/ chunks for comment text is a FALSE-POSITIVE check; compare out/ mtime vs commit time instead).
@@ -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,27 +0,0 @@
---
name: android-nativefile-remotefile-io
description: "Why NativeFile is slow on Android, why RemoteFile (range fetch) can't replace it (asset-protocol Range is broken), measured CDP numbers, and the viable speedups"
metadata:
node_type: memory
type: project
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
---
On-device investigation (Xiaomi 2211133C, Android 16, WebView/Chrome 147, wry 0.54.4) of `src/utils/file.ts` `NativeFile` vs `RemoteFile` Android I/O. Verified live via CDP injection into the running app's WebView.
**Why NativeFile is slow (root cause):** `NativeFile.readData``#readAndCacheChunkSafe` does `open()+seek()+read()+close()` = **4 Tauri IPC round-trips per chunk**, opens a FRESH handle every chunk (never reuses `this.#handle`), and `read()` ships raw bytes across the Android Kotlin↔JS IPC bridge (serialization cost = the unresolved tauri-apps/tauri#9190). Code already notes "~400 ms per IPC round-trip" at `nativeAppService.ts:313`.
**Can RemoteFile replace NativeFile on Android? NO** — and whole-file load is NOT an alternative (RemoteFile's whole point is random access WITHOUT loading the file into RAM). The Tauri/wry Android **asset protocol mishandles Range requests** (still true on WebView 147), which is exactly `RemoteFile.fetchRange`'s mechanism:
- `Range: bytes=START-…` with **START ≥ 1024 → hard `TypeError: Failed to fetch`**; `0 ≤ START < 1024` → body truncated to `1024-START` bytes. Reading the zip central directory (EOF) / OPF / cover = non-zero offsets = all fail. "Known issue" at `nativeAppService.ts:244` — confirmed STILL broken.
- **ROOT CAUSE (localized):** Tauri's `crates/tauri/src/protocol/asset.rs` range logic is CORRECT (seek+read [start,end], 206, Content-Range, Content-Length; `MAX_LEN=1000*1024` cap is BY DESIGN — RemoteFile already chunks at the same `MAX_RANGE_LEN`). The bug is in **wry `src/android/binding.rs`**: it STRIPS the `Content-Length` header ("WebResourceResponse will auto-generate") and hands Android a `ByteArrayInputStream` of the already-sliced partial body + a `Content-Range` header. The Android WebView then **double-applies the offset** (skips another `start` bytes) → `1024-start` truncation, empty body for start≥1024. **Unchanged through wry 0.55.1**, so bumping wry won't fix it; needs an upstream wry patch (or local vendor/patch) and fights Android's intercepted-206 quirks.
- Plain `fetch(assetUrl)` (no Range) returns the full file fast — but loading the whole file defeats RemoteFile's purpose, so NOT a fix.
**Measured (10 MB mobi, 1 MB chunks):** native fresh-handle **44 MB/s** (222 ms) · native one-handle **100 MB/s** (98 ms) · asset plain-fetch **281 MB/s** (35 ms, full file correct). Per-call 4 KB scattered read via NativeFile ≈ **16 ms/op** (kills imports doing many small reads). So: plain-fetch is **6.3×** native and **2.8×** one-handle; just reusing the handle is **2.3×**.
**Per-IPC decomposition (warm):** open 1.33 ms, seek 0.60 ms, read(4 KB) 3.02 ms (read carries ~2.4 ms fixed bridge-serialization beyond the round-trip = the tauri#9190 ceiling), seek+read(1 MB) 8.18 ms.
**SOLUTION (implemented, branch `feat/android-rangefile-protocol`, verified on-device):** a custom `rangefile` URI scheme (`src-tauri/src/range_file.rs`, registered via `register_asynchronous_uri_scheme_protocol`) that carries the byte range in the URL **query** (`http://rangefile.localhost/?path=&start=&end=`) instead of a `Range` header. With NO `Range` header the WebView does no offset re-application and delivers the 200 body verbatim — while bytes still stream through the WebView network stack (not the IPC bridge). Returns 200 + `X-Total-Size` (no `Content-Range`); scope-gated by `asset_protocol_scope().is_allowed()` (same security as asset protocol). TS side: `RemoteFile.fromNativePath(absPath)` (query-range mode, reads `X-Total-Size` on open, `&start=&end=` per fetch, no Range header); wired into `nativeAppService.openFile` Android branch with NativeFile fallback; CSP += `http://rangefile.localhost`.
- **Verified on Xiaomi/Android 16 via CDP:** byte-equal to NativeFile ground truth at ALL offsets (0,1,1024,64K,1M,5M,EOF) — the non-zero starts that failed via asset protocol now work; cache-safe across distinct ranges; real library book opens & renders end-to-end (50 rangefile requests, restored mid-file position). **1.83× faster** small scattered 4KB reads (5.2 vs 9.5 ms); bulk-sequential ≈ par (RemoteFile rarely does whole-file reads; native copyFile fast-path handles those). Why this beat the "200 trick" idea: pre-test showed the WebView re-applies the offset to 200 responses too — it's the *Range request header* that triggers it, so removing the header (range-in-URL) is the actual fix.
- Why this isn't the "single-call IPC command": IPC still pays the tauri#9190 bridge serialization; the rangefile path streams via the network stack. The IPC command (`open+seek+read+close` → 1 IPC, ~2× small reads) remains a valid simpler fallback if the custom scheme ever regresses.
**Side-observation:** installed build's ACL rejects `plugin:fs|close` (allows open/seek/read) → possible `NativeFile` handle-leak / `close()` throw path; verify `fs:default` grants. See [[cdp-android-webview-profiling]].
@@ -1,26 +0,0 @@
---
name: android-open-with-intent-flow
description: "Android \"Open with/Send to Readest\" intent pipeline + why Telegram/cloud-app opens differ from file-manager opens (issue"
metadata:
node_type: memory
type: project
originSessionId: 73ee84b7-27a0-4232-981c-de8235a15f07
---
Android "Open with Readest" / "Send to Readest" file-intent pipeline and the #4521 ("open book from Telegram") diagnosis. Confirmed fixed by #4527.
**Pipeline (ACTION_VIEW = "Open with", ACTION_SEND = "Share"):**
- `NativeBridgePlugin.kt::handleIntent` is the real handler (NOT `MainActivity.kt` — its ACTION_SEND branch is legacy/redundant). → `emitSharedIntent("VIEW"|"SEND", uris)` → JS `useAppUrlIngress` `shared-intent` plugin listener → `app-incoming-url` event → `useOpenWithBooks`.
- VIEW → `openTransient` → straight to reader (ephemeral book, `deletedAt` set, `filePath` = the content:// URI, no library write/upload). SEND → `window.OPEN_WITH_FILES``library/page.tsx::processOpenWithFiles` (full ingest + force cloud upload on mobile).
- content:// read: `nativeAppService.openFile` → if URI contains `com.android.externalstorage` → direct `NativeFile` (real path); else `copyURIToPath``contentResolver.openInputStream` → copy to Cache → `NativeFile`. `basename` here is LEXICAL (`@tauri-apps/api/path`), not a ContentResolver `DISPLAY_NAME` query — but EPUB format is sniffed by zip magic (`document.ts isZip()`), so an extension-less content URI still opens.
- The Tauri deep-link plugin's `getCurrent()`/`onOpenUrl` only fire for configured deep-link domains (`https://web.readest.com`, `readest:`); `content://`/`file://` VIEW intents are filtered out by `DeepLinkPlugin.isDeepLink()`, so file opens flow ONLY through the native `shared-intent` channel, never the deep-link plugin.
**#4521 root cause (Telegram open fails, file-manager works) — TWO independent axes:**
1. **Cold-start delivery.** On cold launch the ACTION_VIEW intent reaches `handleIntent` before the JS `shared-intent` listener registers; upstream `Plugin.trigger()` drops events with no listener. **#4527** added queue+replay (`emitOrQueue`/`pendingEvents` + `registerListener` override) to fix it. **#4527 is on `dev` but NOT in released v0.11.4** (v0.11.4 has #4407 only) — the reporter's likely cause. Logs show `Queued shared-intent payload (no listener yet)` then `Replaying 1 queued event(s) after registerListener`.
2. **Foreign-private-file read.** File-manager/MediaStore opens point at SHARED storage → Readest reads via real path / MediaProvider FUSE (it holds `MANAGE_EXTERNAL_STORAGE`), so the URI grant is irrelevant. Telegram/Gmail/Drive serve an APP-PRIVATE file via their own FileProvider with a TEMPORARY, non-persistable grant (`takePersistableUriPermission` throws → caught) → readable only in-session via `openInputStream`; the transient book's `content://` filePath then breaks on later reopen once the grant dies.
**Verification gotcha (adb, no Telegram):** an adb MediaStore content-URI VIEW intent
`adb shell am start -a android.intent.action.VIEW -d content://media/external/file/<id> -t application/epub+zip --grant-read-uri-permission -n com.bilingify.readest/.MainActivity`
tests the pipeline (Axis 1) but CANNOT reproduce Axis 2 — shared-storage reads via FUSE bypass the grant, so it always succeeds. To reproduce the real failure use a foreign FileProvider source (Gmail/Drive/Outlook attachment "Open with Readest") or a tiny helper APK with its own FileProvider. Get the MediaStore `_id` via `adb shell content query --uri content://media/external/file --projection _id:_data | grep <name>` (MIUI `--where` chokes on `/storage/...` path tokens). Watch `adb logcat | grep -iE "NativeBridgePlugin|Open with FUSE|Failed to (open|import)|Queued|Replaying"`.
Critical files: `src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt`, `src/hooks/useAppUrlIngress.ts`, `src/hooks/useOpenWithBooks.ts`, `src/services/nativeAppService.ts::openFile`, `src/services/bookContent.ts::resolveBookContentSource`.
@@ -1,14 +0,0 @@
---
name: android-sideload-same-versioncode
description: Android sideloaded APK reinstall allows EQUAL versionCode; only strictly-lower is blocked
metadata:
node_type: memory
type: reference
originSessionId: a58a4eba-7a3c-4560-9b52-e3713c6ad211
---
Sideloaded APK installs (Readest's in-app updater path: `installPackage``Intent.ACTION_VIEW` with `application/vnd.android.package-archive` → system package installer, NOT Play Store) permit reinstalling an APK whose `versionCode` is **equal** to the currently installed one — it's an in-place reinstall/update as long as the signing certificate matches. Android's `INSTALL_FAILED_VERSION_DOWNGRADE` only triggers for a **strictly-lower** versionCode. (Play Store, by contrast, requires a strictly-incrementing versionCode — that constraint does NOT apply to sideload.)
Consequence for the nightly update channel ([[android-open-with-intent-flow]] uses the same NativeBridge install path): Tauri derives `versionCode = major*1000000 + minor*1000 + patch`, dropping any prerelease suffix, so all nightlies on base `0.11.4` share `versionCode=11004`. That is FINE — they reinstall over each other and over stable `0.11.4`. Because the base only ever increases (0.11.4 → 0.11.5 → ...), nightly versionCode is monotonic non-decreasing, so there is never a downgrade. No need to derive a per-build versionCode from the date stamp. The app's `versionName` carries the full `0.11.4-2026061406` string, which is what the JS `getAppVersion()` updater comparison uses.
A plausible-but-wrong review claim ("same versionCode means Android refuses the install as not-an-upgrade") confuses Play Store rules with sideload behavior. Corrected by the project owner 2026-06-14.
@@ -1,64 +0,0 @@
---
name: annotation-share-toolbar-4014
description: "Share intent in the selection toolbar + drag-and-drop toolbar customizer (#4014)"
metadata:
node_type: memory
type: project
originSessionId: 507a0166-cb55-4f33-b633-3230c0c514ff
---
#4014 (PR #4570) — added a native "Share" tool to the in-reader text-selection toolbar
plus a drag-and-drop customizer (show/hide + reorder tools). Branch
`feat/annotation-share-toolbar-4014`; spec + plan in
`docs/superpowers/{specs,plans}/2026-06-13-annotation-share-toolbar*`.
Key facts / gotchas:
- **`src/utils/share.ts` is dual-purpose** — it already held share-LINK helpers
(`buildShareUrl`/`parseShareDeepLink` for the `/s/{token}` feature). Text-share was
added there: `shareSelectedText(text, position?, appService?)` and
`canShareText(appService)`.
- **Native share is gated to mobile + macOS only** (`isMobileApp || isMacOSApp`).
Windows/Linux desktop are excluded because `@choochmeque/tauri-plugin-sharekit-api`'s
share UI can FREEZE the app on Windows (issue #4343) — `nativeAppService.saveFile`
gates `shareFile` the same way. Ladder: native → `navigator.share` → clipboard.
`canShareText` = that OR web `navigator.share`; used to gate Share's visibility in
toolbar + customizer + the quick-action dropdown.
- **Toolbar order is a view setting**: `AnnotatorConfig.annotationToolbarItems`
(`src/types/book.ts`), default in `DEFAULT_ANNOTATOR_CONFIG` = the original 8 tools,
**Share hidden by default** (starts in the "Available" tray). No migration needed:
the `{...getDefaultViewSettings(ctx), ...saved}` merge in `settingsService.ts` +
`getToolbarToolTypes(undefined,...)` fallback both yield the default.
- **Pure helpers** in `src/utils/annotationToolbar.ts` (unit-tested) own all
order/visibility logic: `getToolbarToolTypes`/`getAvailableToolTypes` (canShare-gated,
dedup, drop-unknown), `add/remove/reorderToolbar`. `ALL_ANNOTATION_TOOL_TYPES` is
asserted to match the `annotationToolButtons` registry order by a test.
- **Customizer** = `src/components/settings/AnnotationToolbarCustomizer.tsx`, a sub-page
off `ControlPanel` (Behavior panel) via `NavigationRow`. Two `@dnd-kit` zones; chips are
tap-to-toggle AND drag. Design evolved heavily during live browser testing (see gotchas):
- **WYSIWYG**: "In toolbar" renders a faithful preview of the real selection popup —
`selection-popup bg-gray-600 text-white`, icon-only 32×32 buttons (mirrors
`AnnotationToolButton`), `w-fit max-w-full` (content-width, start-aligned). "Available"
tools are labeled icon+text chips. Zone content uses `px-4` to align with `SubPageHeader`.
- **dnd-kit multiple-containers pattern** (NOT the simple single-list one): single
`{toolbar, available}` state; `onDragOver` live-reparents across zones; custom
`collisionDetection` = `pointerWithin``rectIntersection` fallback, snapping a zone-id
hit to the closest inner chip (plain `closestCorners`/`closestCenter` CANNOT drop into an
empty zone). `rectSortingStrategy` (NOT `horizontalListSortingStrategy`, which breaks
wrapped layouts).
- **NO `DragOverlay`** — the settings modal is a CSS-`transform` container, so a
`position:fixed` overlay is offset from the cursor. In-place `useSortable` transform
(relative translate) tracks correctly.
- **`itemsRef` stale-closure fix**: dnd-kit calls `onDragEnd` with the handler captured at
drag START, so the closed-over `items` is stale → a cross-zone drag would bounce back on
release. Read live state from `itemsRef.current` in `handleDragEnd`/tap handlers.
- **Add all** (rebuilds in canonical `ALL_ANNOTATION_TOOL_TYPES` order, NOT prior order) /
**Clear all** header buttons.
- Cross-platform guard: when editing on a `!canShare` device, `persist` re-appends a
`share` that was synced-in but hidden, so it isn't dropped for share-capable devices.
- **Empty toolbar suppresses the popup**: when `getToolbarToolTypes` yields [] (user cleared
all), `Annotator.tsx` does NOT render the `AnnotationPopup` on a plain selection (gated on
`toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0`) — no
empty bar, but highlight-edit/notes popups still work. (Earlier tried fallback-to-default;
user wanted full suppression instead.)
- Adding a tool to the union (`AnnotationToolType`) is compile-checked: the
`createAnnotationToolButtons` generic in `AnnotationTools.tsx` requires every member.
@@ -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,17 +0,0 @@
---
name: biometric-app-lock-4645
description: "Biometric (fingerprint/Face ID) startup unlock layered over the PIN app-lock; gotchas for applock-store seeding, mobile-cfg crate, and scoped i18n"
metadata:
node_type: memory
type: project
originSessionId: 7d0f633b-0e69-405e-a4b4-5a1b19723d86
---
Biometric app-lock (#4645, PR #4650, branch `feat/biometric-app-lock`): biometrics unlock at startup on Android/iOS, app PIN as fallback; desktop/web unchanged. Layered over the existing PIN lock — `pinCodeEnabled` stays the master switch, PIN crypto in `libs/crypto/applock.ts` untouched. All plugin access isolated behind `src/services/biometric.ts` (guarded no-op off mobile; `authenticate` uses `allowDeviceCredential:false` so PIN is the only fallback). New setting `biometricUnlockEnabled` defaults true only for NEW mobile setups; existing PIN users (undefined→off) opt in via a mobile-only toggle.
Non-obvious gotchas (cost real review/rework here):
- **`AppLockScreen` must read startup-snapshot settings from `appLockStore`, NOT `settingsStore`.** `Providers` seeds ONLY the app-lock store via `useAppLockStore.initialize()` (from its own `loadSettings()`), before the gate mounts. `settingsStore.settings` starts `{}` and is seeded later by page-level init — reading the flag from `settingsStore` in the gate RACES and silently no-ops. Fix = thread the value through `initialize()` like `pinHash`/`pinSalt`.
- **`tauri-plugin-biometric` is `#![cfg(mobile)]`** — empty on desktop, so registration must be `#[cfg(any(target_os="ios",target_os="android"))]`-gated (like `haptics`/`sign-in-with-apple`). Desktop `clippy:check` does NOT compile that line, so the Rust side needs a real device build to verify. The dep pin lands in the **workspace-root `Cargo.lock`** (resolved when cargo runs), NOT `src-tauri/Cargo.lock` (which doesn't exist/track here).
- **Scoped i18n without churn:** `public/locales/en` is NOT scanner-managed (key-as-content fallback). Running the full `i18n:extract` reconciles ALL strings and pulls in unrelated drift already on main (e.g. Word Lens keys). For a clean PR, discard the scanner output and add only your new keys to the 33 langs in `i18n-langs.json`. "Face ID"/"Touch ID" are Apple brands — keep verbatim in every locale.
Related: [[ios-instant-dict-double-popup]] (same applock/gate area), [[custom-fonts-reincarnation-4410]] (settings-sync flag patterns).
@@ -1,14 +0,0 @@
---
name: book-actions-platform-surfaces
description: Where to add a library book action so it reaches every platform (context menu is desktop-only)
metadata:
node_type: memory
type: project
originSessionId: 4efb7b40-cce1-4742-9730-7e93e643d196
---
The library book **context menu** (`BookshelfItem.tsx::bookContextMenuHandler`, native `Menu.new`) only renders where `appService.hasContextMenu` is true — that is **Tauri desktop only** (`nativeAppService.ts`: `!(ios||android)`). It is **false on web AND on iOS/Android**. So a book action added only to the context menu (+ `getBookContextMenuItemIds` in `libraryUtils.ts`) never reaches phone/web users.
The cross-platform home for book-level actions is the **`BookDetailView` action-icon row** (`src/components/metadata/BookDetailView.tsx`), shown in `BookDetailModal`, reachable on every platform (BookItem tap → details, `Bookshelf.tsx::handleShowDetailsBook`). That row is `flex-nowrap` inside a fixed `h-32` column and already holds up to ~5 icons (Edit/Delete/Download/Upload/Export) — adding more risks phone overflow; keep additions to one small icon.
**Rule:** desktop-only fast path → context menu; must reach mobile → BookDetailView (or both). Example: the "Search on Goodreads" feature (#4543) added both — `searchGoodreads` context-menu id + a `FaGoodreads` button in BookDetailView, opening `getGoodreadsSearchUrl` via [[open-external-url-helper]]. In-reader highlighted-text Goodreads search is a built-in [[web-search-provider]] entry instead.
@@ -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,20 +0,0 @@
---
name: cdp-android-webview-profiling
description: "How to drive the Android WebView via CDP (adb) to run JS probes/benchmarks inside the live Readest app, and the gotchas that waste time"
metadata:
node_type: memory
type: reference
originSessionId: 8057ac9c-2e3e-446d-86aa-29baddfbfe66
---
Driving the on-device Readest WebView via CDP to run JS probes/benchmarks **inside the live app** (no rebuild) — used for the NativeFile/RemoteFile I/O study ([[android-nativefile-remotefile-io]]).
**Setup:** app must be running → `adb shell cat /proc/net/unix | grep webview_devtools_remote_<pid>``adb forward tcp:9222 localabstract:webview_devtools_remote_<PID>`. Discover targets with a Node `http.get` to `/json/list` (set header `Host: localhost`) — **curl mishandles the WebView's HTTP framing and hangs/returns empty**. Connect `ws://127.0.0.1:9222/devtools/page/<id>`, then `Runtime.enable` + `Runtime.evaluate {expression:'(async()=>{...})()', awaitPromise:true, returnByValue:true}`. Helper scripts kept in `/tmp/cdp/` (eval.mjs, disc.mjs).
**Gotchas that burned time:**
- **Locked device freezes `fetch`.** When the screen is locked the page is `visible:false`; Chromium freezes the network task queue so EVERY `fetch()` (same-origin and asset) hangs forever — but Tauri `invoke()` still resolves. Must have the user **unlock + keep Readest foregrounded**. Set `svc power stayon true` + `settings put system screen_off_timeout 1800000` after unlock (revert `stayon false` when done).
- **`visible:false` also throttles `setTimeout`** (background timer coalescing → ~60 s). Don't rely on setTimeout guards in probes when the page may be hidden; `invoke`-only probes still work hidden.
- `window.__TAURI_INTERNALS__` is ALWAYS injected (independent of `withGlobalTauri`) → use `.convertFileSrc(path)` and `.invoke(cmd,args)` from injected JS. Android asset URL = `http://asset.localhost/<encodeURIComponent(path)>`.
- Real book files live in **internal** storage (`/data/user/0/com.bilingify.readest/...`), not the external `Android/data/.../files` dir (that's `forbidden path` to the fs plugin). `$APPCACHE` = `/data/user/0/com.bilingify.readest/cache` holds import temp copies (in asset scope `$APPCACHE/**/*`). `adb run-as` is denied on the release build.
- fs plugin invokes: `plugin:fs|open{path,options}→rid`, `seek{rid,offset,whence}` (Start=0), `read{rid,len}`→ArrayBuffer whose **last 8 bytes are bigendian nread**, `close{rid}` (**not ACL-allowed** in the installed build). `read_dir`/`stat` on out-of-scope abs paths return `forbidden path`. Tauri v2 `BaseDirectory`: AppData=14, AppLocalData=15, AppCache=16.
- zsh: `$PIPESTATUS[0]` is a bash-ism (empty in zsh; use `$pipestatus[1]`) — don't trust it for exit codes.
@@ -1,26 +0,0 @@
---
name: ci-pr-delivery-and-push
description: Delivering small PRs from a dirty dev tree without a worktree; the slow pre-push hook + proxy SSH-drop and its keepalive fix
metadata:
node_type: memory
type: project
originSessionId: c1097233-8b53-422a-98ec-3f0146f32f6b
---
How CI/config PRs get delivered in this repo when `dev` has unrelated uncommitted WIP, and the push gotcha.
**Packaging a commit onto a fresh PR branch WITHOUT `pnpm worktree:new`** (the worktree script does a full `pnpm install` + `tauri android init` + icon gen — disproportionate for a YAML/package.json-only PR, and you can't `git checkout` a branch in the dev tree because the user's WIP blocks it):
1. Edit the target files in the dev tree (only files NOT in the user's WIP set — verify with `git status --short -- <files>`), `git add` just those, commit on `dev` (mirrors how the user wanted the pin committed).
2. Re-parent onto `origin/main` (or onto the existing PR-branch tip for a fast-forward add) via a temp index — no checkout, no worktree, dev working tree untouched:
```
export GIT_INDEX_FILE=$(mktemp); git read-tree <BASE>
git update-index --cacheinfo 100644,$(git rev-parse HEAD:<path>),<path> # per changed file
TREE=$(git write-tree); unset GIT_INDEX_FILE
NEW=$(git log --format=%B -n1 HEAD | git commit-tree $TREE -p <BASE>)
git update-ref refs/heads/<branch> $NEW
```
3. Verify `git diff --stat <BASE>..<branch>` shows ONLY the intended files, then push.
This is how PR #4547 (pin `android-emulator-runner` + shard `test_web_app`) was built on top of `origin/main` while `dev` carried 49 files of unrelated dictionary/goodreads WIP.
**Push gotcha (now fixed in `~/.ssh/config`):** `git push` opens the SSH connection BEFORE running the pre-push hook; the husky hook runs the FULL vitest suite (~55s, 5271 tests) + format + lint. The user pushes through a SOCKS proxy (`nc -x 127.0.0.1:8119` → `ssh.github.com:443`), so the idle connection got dropped during the hook → "Broken pipe", ref never transferred (remote stayed at old SHA — always `git ls-remote` to confirm). Fix added: `ServerAliveInterval 15` + `ServerAliveCountMax 60` under `Host github.com`. Also: **`--no-verify` is safe once the hook has already passed** on the same tree — re-running it just re-opens the idle window. See also [[feedback_dont_push_every_change]], [[feedback_use_worktree]].
@@ -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,47 +0,0 @@
---
name: cover-bg-image-texture-suppression
description: Cover painted via body background-image vanished under an active bg texture (parchment) because textureAwareBackground misclassified it as transparent
metadata:
node_type: memory
type: project
originSessionId: 9d32520c-53be-4871-9104-d93617736e30
---
EPUB cover pages that paint the cover via a `<body>` CSS `background-image`
(EPUB sets `background-color` transparent + `background-size:100% 100%`, no
`<img>` — e.g. Sigil/duokan样书《商梯》) showed the **background texture instead
of the cover** on the first page. Reported "Xiaomi only" but it's
texture-only, not Android-only.
Root cause (verified on-device via adb+CDP, Xiaomi 13 WV147): foliate
`packages/foliate-js/paginator.js` `textureAwareBackground(resolved, hasTexture)`.
foliate captures the body bg into `view.docBackground` via
`getComputedStyle(body).background` (the SHORTHAND), which always serializes the
transparent background-*color* first: `rgba(0, 0, 0, 0) url("blob:…") no-repeat
fixed 50% 50% / 100% 100% …`. The old `isTransparent` regex
`/^\s*(transparent|rgba\(0,\s*0,\s*0,\s*0\))/` matched that prefix → under an
active texture (`--bg-texture-id` != none) it returned `''` → no bg segment in
the host `#background` → texture (`.foliate-viewer::before`) showed through. With
no texture it worked (returns the cover bg unchanged), which is why desktop/
default looked fine.
Fix: a bg that carries an image is NOT transparent. Add `hasImage =
/\burl\(/i.test(resolved)` and gate `isTransparent` on `!hasImage`. A full-page
cover should occlude the texture; plain `none` transparent pages still drop so
the texture shows through. Helps scrolled (line ~1464) and paginated (~1482)
callers alike. Test: `paginator-background-segments.test.ts` (added the
url()-keeps case; kept the existing `none`-drops case).
NOT the bug (ruled out on-device): Rust `parse_epub_metadata` cover EXTRACTION
(library thumbnail was correct), shorthand serialization (WV147 emits the url
fine), the cover blob URL (loads 1200x1800 fine), `background-attachment:fixed`
(Android falls back to scroll but the segment sets `background-attachment:
initial` anyway). Related: [[paginated-texture-occlusion-4399]],
[[dark-mode-texture-body-bg-4446]], [[paginator-swipe-bg-flash]].
CDP verify recipe: pid changes per app restart — re-derive socket from
`/proc/net/unix` (`webview_devtools_remote_<pid>`), `adb forward tcp:9333
localabstract:…`; curl mishandles WV HTTP framing → raw-socket fetch `/json`;
pure-python WS client (omit Origin for M111+); paint a 50%-width test segment
with the cover blob bg into `#background` + `Page.captureScreenshot` to see
cover-vs-texture side by side.
@@ -1,20 +0,0 @@
---
name: cover-stale-inplace-mutation-memo
description: Library cover (or any memoized child) not updating until refresh — in-place object mutation defeats React.memo
metadata:
node_type: memory
type: project
originSessionId: ec78c172-79e7-448c-8671-780dcc115613
---
Symptom: edit a book's cover in Book Details → Save → return to library, the cover shows the OLD image; a full page refresh fixes it. Title/author DO update.
Root cause: `handleUpdateMetadata` in `src/app/library/page.tsx` mutated the existing `book` object IN PLACE (`book.metadata = …; book.coverImageUrl = …; book.updatedAt = …`) then passed that same reference to `updateBook`. `<BookCover>` (`src/components/BookCover.tsx`) is `React.memo`'d with a custom comparator reading `coverImageUrl`/`metadata.coverImageUrl`/`updatedAt` off the book. Because the previous-render snapshot (`prevProps.book`) is the *same mutated object*, every field compares equal → memo skips → cover never re-renders. Title updates because `BookItem` is NOT memoized. Refresh works because `loadLibraryBooks` (`libraryService.ts`) strips `coverImageUrl` on save and REGENERATES it from `${hash}/cover.png` on load (the file was overwritten by `updateCoverImage`).
KEY INSIGHT: cloning inside `updateBook` would NOT fix it — once the original object is mutated, `prevProps` already reads the new values. The fix must leave the object React holds as `prevProps` untouched.
Fix (PR for fix/txt-open-with-conversion): pure helper `getBookWithUpdatedMetadata(book, metadata)` in `src/utils/book.ts` returns a NEW book object (`{...book, metadata, title, author, primaryLanguage, updatedAt, coverImageUrl}`); `handleUpdateMetadata` uses it instead of mutating. Cover URL is set from `metadata.coverImageBlobUrl || metadata.coverImageUrl` (cached/blob URL is a unique path = the new image; `'_blank'` for remove). Unit test asserts immutability of the input + new reference.
General rule: when a memoized child reads fields off a store object, NEVER mutate that object in place to "update" it — build a new object. Same trap could bite any `React.memo` field comparator in this codebase.
On-device CDP verification (reusable): the cover SET path uses the native Tauri file picker (`selectFiles``openDialog`), NOT automatable via CDP; the installed emulator app is the released bundled build (`http://tauri.localhost/...`), not the dev server. So I verified the MECHANISM directly in the live WebView: extracted the zustand library store from the React fiber tree (the library page calls `useLibraryStore()` with NO selector, so its fiber hook `memoizedState` holds the full state incl. `library`/`setLibrary`/`updateBook`), injected an Alice book, then A) mutated it in place + `setLibrary([sameRef])` → rendered `<img src>` stayed stale (bug), B) `setLibrary([{...book,coverImageUrl:NEW}])``<img src>` updated immediately (fix). Restore with `Page.reload` (injected book was in-memory only). See [[cdp-android-webview-profiling]], [[android-cdp-e2e-lane]].
@@ -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,73 +0,0 @@
---
name: dark-mode-texture-body-bg-4446
description: "#4446 dark-mode bg texture occluded by body.theme-dark opaque bg !important (style.ts getDarkModeLightBackgroundOverrides); verified on Xiaomi via CDP; multiview = patch ALL section iframes"
metadata:
node_type: memory
type: project
originSessionId: 61f864c0-488e-466c-89e9-86df66b57d42
---
RESOLVED — PR #4564 MERGED 2026-06-12 (`fix/dark-mode-texture-4446`, built on origin/main
via temp-index plumbing from the dirty dev tree; branch + local mods cleaned after merge).
User-verified on the Xiaomi via `pnpm dev-android` (full aarch64 build + `adb install -r`,
~7 min).
#4446 remaining case (after [[paginated-texture-occlusion-4399]] fixed light mode): in
**dark mode** the bg texture is absent in paginated entirely, and absent in the scrolled
text area while header/footer still show it.
**Root cause (verified live on Xiaomi 2211133C via CDP, no rebuild).**
`getDarkModeLightBackgroundOverrides` (style.ts ~194) emits
`body.theme-dark { background-color: ${bg} !important; }`, applied when
`isDarkMode && !overrideColor` (style.ts ~320). That paints every section iframe's body
opaque dark (`rgb(34,34,34)`), which occludes the host `.foliate-viewer::before` texture.
Downstream it also poisons foliate: `resolveBackground(view.docBackground)` resolves the
opaque body color, so `textureAwareBackground` keeps the paginated `#background` segment
and the scrolled `view.element` inline bg opaque too. The #4399 fix is intact (container
is transparent); this is a second, dark-mode-only occluder one layer deeper.
- Paginated: segment + iframe body span the full viewer → no texture anywhere.
- Scrolled: iframes cover only the text column → header/footer strips keep texture
(grid-cell + the #4486 notch overlay, see [[notch-mask-texture-4486]]).
**Proof:** injecting `body.theme-dark{background-color:transparent !important}` into ALL
section iframes + clearing the segment/view inline bgs reveals the leaves texture in both
modes instantly.
**Regression source (git-proven):** commit `176b950c9` = PR #4392 (2026-06-01, shipped
v0.11.4) added the rule. NOT foliate-js — the foliate swipe-flash regression (#4399,
167757a→142bf11) broke LIGHT-mode textures in the same release window, which is why it
looked like one. The transformStylesheet light-bg rewriter (also #4392) was EXONERATED
for the repro book: Alice's stylesheets have zero body/html background rules (verified by
on-device stylesheet enumeration). **No #4392 revert needed** — its callout attribute
selectors + rewriter fix real legibility bugs (#4028; #4419/#4426 build on them).
**Fix (applied):** make the rule `background-color: transparent !important`
UNCONDITIONALLY, not gated on hasBackgroundTexture, because foliate captures
`docBackground` once per section load (paginator.js `load` listener; `setStyles` re-runs
`#replaceBackground` but never re-captures), so a texture-gated body bg would go stale on
live texture toggling. Visuals without texture are identical: the dark fill comes from the
paginator container `fallbackBg` / reader grid cell. Book-forced light page bgs stay
neutralized (theme-dark fill shows through); page-level rewriter output is cascade-beaten
by our `!important` body rule and later-in-head `html` rule; only book `!important`
page rules survive — consistent with the #4399 "book-forced opaque page wins" policy.
Test: `style-get-styles.test.ts` "#4446" cases. E2E device-verified: with the fixed CSS
present at load, capture is transparent and the paginated segments array comes out EMPTY.
**Verification gotchas (cost ~30 min):**
- **Multiview!** The renderer shadow root holds MULTIPLE section iframes (adjacent preload).
Patching `sr.querySelector('iframe')` hits a section possibly 18k px off-viewport →
"fix didn't work". Patch every `sr.querySelectorAll('iframe')`.
- `elementsFromPoint` reports the iframe ELEMENT's computed bg (transparent) — the
occluding paint is its content document's body, invisible to the top-doc/shadow stack walk.
- Switching `renderer.setAttribute('flow', ...)` can reload section docs and silently wipe
styles injected into them (but not always — re-check after every flow switch).
- Pseudo-element paint test: patch the `#background-texture` style text with
`background-color: red` — if red doesn't show, the pseudo is occluded, not broken.
- **Stale preload views**: after patching styles via `renderer.setStyles`, views loaded
PRE-patch keep their opaque `docBackground` and `#clearViewsExcept` keeps `|iindex|≤2`
across navigation — an opaque segment can come from a kept old view, not the fresh one.
Jump ≥3 sections to guarantee fresh captures.
- **Capture-time instrumentation**: the paginator dispatches `load` synchronously right
BEFORE `docBackground = getBackground(doc)` — an event listener on the renderer sees
exactly what the capture will see (class list, computed bgs, active rules).
@@ -1,47 +0,0 @@
---
name: dblclick-drag-pageturn-4524
description: Web double-click-and-drag selection turned the page; deferred single-click fired mid-drag while button held
metadata:
node_type: memory
type: project
originSessionId: 5fe20151-9768-4e7c-9cee-2aa25da5318c
---
#4524: on Readest Web, **double-click a word then drag** to extend the native
selection also **turned the page** (a plain double-click did not). The user
expects browser-native double-click-drag word-by-word selection without a page
turn.
**Root cause** (`src/app/reader/utils/iframeEventHandlers.ts` `handleClick`):
the first click of a potential double-click schedules a deferred
`postSingleClick()` after `DOUBLE_CLICK_INTERVAL_THRESHOLD_MS` (250ms).
- Plain double-click: the 2nd `click` fires fast, updates `lastClickTime`, posts
`iframe-double-click`; when the 1st click's timer fires, the
`Date.now() - lastClickTime >= 250` check is now false → single-click
suppressed → no page turn.
- Double-click **+ drag**: the user holds the button down on the 2nd click and
drags, so the 2nd `mouseup`/`click` is delayed past 250ms. At first-click+250ms
`lastClickTime` is still the 1st click → check passes → `iframe-single-click`
posted **while the button is still held**`usePagination.handlePageFlip`
turns the page.
**Fix**: module-level `isMouseDown` flag (set in `handleMousedown`, cleared in
`handleMouseup`); the deferred `postSingleClick()` returns early when
`isMouseDown` is true (a drag is in progress). Cannot affect a normal single
click — `isMouseDown` is false by the time its deferred timer fires; only a
held button (drag) suppresses it.
**Verification gotcha**: reproduced live by dispatching synthetic
mousedown/mouseup/click to the reading iframe doc (found via deep shadow-DOM
walk; the foliate iframe sits in nested shadow roots, `document.querySelectorAll('iframe')`
returns 0). Watch `iframe-single-click` on `window` 'message' + the
`.progress-info-label` "N / M" page text. NOTE: back-to-back synthetic gestures
share the module's real `setTimeout` deferrals and `lastClickTime`, so a
follow-up "normal single click" repro can spuriously show no single-click — the
vitest unit test (fake timers) is the authoritative regression check, not
chained browser repros. Iframe listeners are attached once
(`detail.doc.isEventListenersAdded`), so a full page reload is required to pick
up edits — Fast Refresh won't re-bind them.
Test: `src/__tests__/reader/utils/iframeEventHandlers.test.ts`. Related:
[[foliate-touch-listener-capture-phase]], [[progressbar-focus-ring-4397]].
@@ -1,21 +0,0 @@
---
name: dependabot-pnpm-overrides
description: How to fix transitive-dependency Dependabot/CVE alerts in the readest monorepo (pnpm overrides location + style)
metadata:
node_type: memory
type: project
originSessionId: cdc9c728-a2c7-4a9e-b87a-44046560a4fa
---
Transitive npm security advisories (Dependabot alerts against `pnpm-lock.yaml`) are fixed by pinning a **minimum patched version** in the `overrides:` block of **`pnpm-workspace.yaml`** at the monorepo root — NOT `package.json`'s `pnpm.overrides`.
**Why:** the repo uses pnpm 9+ (`pnpm@11.x`), which reads `overrides`/`patchedDependencies`/`catalog` from `pnpm-workspace.yaml`. A `pnpm.overrides` block added to root `package.json` is silently ignored — `pnpm install` runs fast and the lockfile doesn't change. There is already a long list of security pins in that `overrides:` block (glob, undici, qs, body-parser, etc.).
**How to apply:**
1. `gh api repos/readest/readest/dependabot/alerts/<N>` → get package + `first_patched_version` + vulnerable range.
2. Confirm parent ranges allow the patch (`cat node_modules/.pnpm/<parent>@*/.../package.json | grep '"<pkg>"'`).
3. Add/raise the entry in `pnpm-workspace.yaml` `overrides:` in the existing style: `<pkg>: '>=<patched>'` (e.g. `shell-quote: '>=1.8.4'`). **Check for an existing too-low pin** — e.g. `qs: '>=6.14.2'` still allowed the vulnerable 6.15.1; had to raise to `'>=6.15.2'`.
4. `pnpm install --lockfile-only` then `pnpm install`. Verify with `pnpm why -r <pkg>` (should show only the patched version). Stale dirs may linger in `node_modules/.pnpm` but are harmless if the lockfile has zero refs to the old version.
5. Dependabot **alert numbers are not GitHub issue numbers** — don't use `Closes #N`; alerts auto-dismiss when the vulnerable version leaves the default-branch lockfile. Reference the `/security/dependabot/<N>` URLs in the PR body instead.
First done in PR #4523 (shell-quote 1.8.4 / GHSA-w7jw-789q-3m8p, qs 6.15.2 / GHSA-q8mj-m7cp-5q26). Diff stays scoped to just the bumped packages; prefer this over `pnpm update <pkg>` which incidentally refreshes unrelated in-range patches (e.g. react-is). See [[feedback_pr_new_branch]] [[feedback_use_worktree]].
@@ -1,24 +0,0 @@
---
name: deploy-workers-dev-sni-proxy
description: "pnpm deploy crashes in China — workers.dev SNI-blocked, wrangler ws WebSocket bypasses http_proxy; fix = NODE_OPTIONS preload"
metadata:
node_type: memory
type: project
originSessionId: 65342d98-7939-41ed-9e10-2efc466946b1
---
`pnpm deploy` (and `pnpm upload`) crashed for chrox (behind GFW, Privoxy at `http://127.0.0.1:8118`) with an **unhandled `ws` `'error'` event → Node process crash** (ETIMEDOUT to Facebook/Vultr/Twitter IPs).
**Trigger:** `opennextjs-cloudflare deploy`/`upload` ALWAYS runs `populateCache({target:"remote"})` BEFORE the real deploy (no skip flag; only `cacheChunkSize`/`env` knobs). That step calls wrangler's `unstable_startWorker({remote:true})`, which opens a **WebSocket** to a `*.workers.dev` edge host. (`preview` uses `target:"local"` → unaffected.)
**Root cause (two layers):**
1. `*.workers.dev` is **SNI-blocked** by the GFW, not merely DNS-poisoned. Proof: encrypted DoH gives the REAL Cloudflare IPs (104.18.x), but a *direct* TLS connect to that correct IP with SNI=workers.dev is still `Connection reset by peer` before TLS starts. So **DoH/dnscrypt-proxy does NOT help** — the connection must avoid being made directly at all.
2. wrangler's REST calls honor `http_proxy` (undici `ProxyAgent`/`EnvHttpProxyAgent`), but the raw `ws` handshake falls back to `https.globalAgent` and **ignores proxy env**. So it connects directly → SNI reset → crash. The crash fires async (unhandled WS 'error'), so `opennextjs-cloudflare deploy`'s `await` can't catch it.
**Attempt 1 — proxy preload (tried, then REMOVED):** a zero-dep preload that replaced `https.globalAgent` with a `CONNECT`-tunnel agent (CONNECT hides the SNI = defeats the block + does remote DNS; loopback bypassed so the local populate worker on 127.0.0.1 still works). Verified `https.get('https://workers.dev')` → 301 via proxy. This got PAST the WebSocket crash — the local populate worker started and enumerated all 17 cache assets — **BUT the actual R2 writes through the remote binding then timed out** ("Failed to send request to R2 worker: aborted due to timeout", retrying forever). The proxy establishes the connection but can't reliably carry the sustained cache-write traffic. So the preload alone is NOT sufficient. Deleted it.
**Attempt 2 — replicate `wrangler deploy` in the npm script (tried, then reverted):** skip populate by bypassing `opennextjs-cloudflare deploy` and running `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV=false OPEN_NEXT_DEPLOY=true wrangler deploy` directly (traced from `runWrangler`: stock deploy's real step is plain `wrangler deploy` vs `wrangler.toml` which has `main=.open-next/worker.js`+all bindings; no generated config/skew mapping; the env flag stops wrangler 4.x auto-loading `.env`/`.dev.vars` into the worker — OpenNext's adapter handles env). Works, but hacky (replicates internals, drift risk).
**Fix that SHIPPED — config flag (cleanest).** populateCache is gated by `if (!config.dangerous?.disableIncrementalCache && incrementalCache)`. So in `open-next.config.ts`: `config.dangerous = { ...config.dangerous, disableIncrementalCache: true }`. This makes the STOCK `opennextjs-cloudflare deploy`/`upload` skip populate (no script hack, no env flag, no drift) — reverted package.json to stock. Caveat: it's the SAME flag the runtime reads, so it ALSO disables the runtime incremental cache — **but readest uses ZERO ISR (no `revalidate`/`unstable_cache`/`'use cache'`/`generateStaticParams`), so runtime caching is a no-op anyway → no real loss.** Re-enable = delete the one line (from a network that can reach the CF edge). `defineCloudflareConfig` returns `OpenNextConfig` (broad type; `dangerous.disableIncrementalCache?: boolean` exists), tsgo+biome clean. `preview` was always fine (local populate).
Related: [[turbopack-build-cache-oom-docker-standalone]], [[r2-rclone-createbucket-403]].
@@ -1,45 +0,0 @@
---
name: deps-security-overrides-workflow
description: "How to fix transitive npm Dependabot alerts in the readest monorepo (pnpm-workspace overrides, where config lives, tauri-plugins is separate)"
metadata:
node_type: memory
type: reference
originSessionId: c61e7dd2-4033-4bd1-8f32-22056e4ef322
---
Fixing transitive npm Dependabot security alerts (manifest `pnpm-lock.yaml`).
**Where pnpm config lives (non-obvious):** the MAIN monorepo's `overrides`,
`patchedDependencies`, `onlyBuiltDependencies`, `allowBuilds` are in
**`pnpm-workspace.yaml`** (newer pnpm style) — NOT the root `package.json`
(root `package.json` has no `pnpm` section). The root `pnpm-lock.yaml` is what
Dependabot scans; alerts report manifest `pnpm-lock.yaml` = this root lockfile.
**`packages/tauri-plugins` is a SEPARATE project**, not part of the main pnpm
workspace. It's a git submodule (`tauri-plugins-workspace`) with its OWN
`pnpm-lock.yaml` and its own `package.json` `pnpm.overrides` +
`minimumReleaseAge: 4320`. The `minimumReleaseAge` (3-day age gate) applies ONLY
there — the main monorepo has NO age gate, so `^X` specs resolve to the very
latest matching version. Dependabot does not scan the tauri-plugins lockfile.
`pnpm-workspace.yaml` `packages:` = `apps/*`, send-email worker, extensions,
`packages/foliate-js` (NOT tauri-plugins).
**Recipe for a transitive advisory:**
1. Add `pkg: '>=X.Y.Z'` to the `overrides:` block in `pnpm-workspace.yaml`
(forces all transitive instances up). For risky 0.x packages, BOUND it like
the existing `vite: '>=7.3.2 <8'` (e.g. `esbuild: '>=0.28.1 <0.29'`).
2. For packages that are also DIRECT deps, bump the spec in
`apps/readest-app/package.json` too (e.g. the vitest family:
`vitest`, `@vitest/browser-playwright`, `@vitest/browser-webdriverio`,
`@vitest/coverage-v8` — move in lockstep).
3. `pnpm install`, then `grep -oE "pkg@[0-9.]+" pnpm-lock.yaml | sort -u` to
confirm no vulnerable versions remain.
4. Verify: `pnpm test` + `pnpm lint` + `pnpm build-web` (the last exercises
esbuild in the OpenNext/Cloudflare bundle path).
**Override applicability:** an override forces a transitive version regardless
of the parent's declared range ONLY when the package is a regular dep (no peer
warning). esbuild is a regular dep of vite; vite 7.3.x pins esbuild `^0.27.0`
but esbuild 0.28.x is API-compatible for vite's usage (0.28 changelog = install
integrity + minifier/codegen fixes). Verified via PR #4618 (alerts #238/#239
esbuild→0.28.1, #240 @vitest/browser→4.1.9).
@@ -1,45 +0,0 @@
---
name: dict-import-contenturi-filename-4489
description: "Android dict import \"incomplete bundle\" — ext-less content URIs; classify used getFilename (string) not basename (content resolver)"
metadata:
node_type: memory
type: project
originSessionId: 92f1011c-89b9-4ae1-b128-f24cfb4462a8
---
#4489 / #4472: importing a StarDict bundle (`.ifo`+`.idx`+`.dict.dz`) on **some** Android
devices fails with "Skipped incomplete bundles"; works on Xiaomi/Boox and web.
**Root cause — two filename resolvers diverged.** Tauri's Android `path.file_name`
(used by `basename`) special-cases `content://`/`file://` URIs and calls the native
`getFileNameFromUri` plugin → **content resolver DISPLAY_NAME** (real filename WITH ext).
See `tauri-2.11.2/src/path/android.rs`. Our `getFilename()` (`src/utils/path.ts`) is pure
JS string-parse of the URI. On devices whose SAF URI is an **opaque ext-less document id**
(e.g. `content://com.android.providers.downloads.documents/document/msf%3A20`), getFilename
`msf%3A20` (no ext) while basename → `21cen.dict.dz`. Working devices return
`primary%3ADictionaries%3A21cen.dict.dz` (ext in the URI) so getFilename happens to work.
The OLD `selectFileTauri` extension filter ALREADY used `basename` (so files passed the
filter and got imported), but **threw the resolved name away** and returned the raw URI.
Then `dictionaryService.classify()` re-derived the name with `getFilename()` → no ext →
every file orphaned → "incomplete bundle". The bug is the divergence, not the picker.
**Fix (PR for #4489):**
- `SelectedFile.name?: string` added (`useFileSelector.ts`).
- `resolveTauriFileName(path, appService)`: `basename` for `content://` / iOS `file://`,
else `getFilename` — resolved ONCE in `selectFileTauri`, reused by the ext filter AND
stored on `SelectedFile.name`. Removed `processTauriFiles`.
- `classify()` (`dictionaryService.ts`): `source.file?.name ?? source.name ?? getFilename(path)`.
- Test: `groupBundlesByStem.test.ts` — ext-less content URIs + `name` form a complete bundle.
- Also fixes #4472 issue 3 (uploaded dict files renamed to the SAF dir path).
**Emulator repro (real granted URI, no rebuild needed):** drive SAF picker → "Downloads"
location → returns `content://...downloads.documents/document/msf%3A<id>` (ext-less).
basename resolves the real name ONLY for granted URIs (synthetic URIs → "path does not have
a basename"). Verified via CDP `invoke('plugin:path|basename',{path,ext:null})`. See
[[cdp-android-webview-profiling]] for the CDP harness (`src/__tests__/android/helpers/`).
**Also fixed same turn:** e-ink "black spot" on the Settings→Dictionaries `+` badges
(Import Dictionary / Add Web Search) — add `eink-inverted` to the round badge span, mirroring
the font import button #4454 (`globals.css` `[data-eink] .eink-inverted` → base-content bg +
base-100 icon).
@@ -1,22 +0,0 @@
---
name: dict-lemmatization-4574
description: "Dictionary lookup lemmatizes inflected words (ran→run, mice→mouse) before lookup; pluggable per-language registry, English impl, candidate-chain integration"
metadata:
node_type: memory
type: project
originSessionId: d4206b72-47da-4c3d-adab-04df32c1137a
---
#4574 FR: dictionary lookup should normalize inflected forms before lookup. Dicts that store only base headwords (Oxford Dictionary of English, Cambridge, Longman) miss `ran`/`mice`/`children`/`analyses`/`realised` even though `run`/`mouse`/`child`/`analysis`/`realise` exist.
**Integration point** (single, central): `src/services/dictionaries/lookupCandidates.ts` `buildLookupCandidates(word, lang?)` — appends `getLemmaCandidates(lower, lang)` to the tail of `[trimmed, lower, title, upper]`. Lemmas sit AFTER exact/case so exact match always wins. The pre-existing lookup loop in `DictionaryResultsView.tsx` (`useDictionaryResults`, shared by desktop popup + mobile sheet) tries each candidate and breaks on first non-empty hit; wiring was a one-liner passing `langCode` (already in effect scope) as the 2nd arg. Applies to ALL definition providers (mdict/stardict/dict/slob + online builtins).
**New module** `src/services/dictionaries/lemmatize/`:
- `index.ts``getLemmaCandidates(word, lang)` + `Record<string, Lemmatizer>` registry (`Lemmatizer = (word)=>string[]`). Lang normalized via `normalizedLangCode` (utils/lang.ts) to primary subtag. **Missing/empty lang defaults to `'en'`** (`normalizedLangCode(lang) || 'en'`); **explicit non-English with no registered lemmatizer → `[]`** (we never force English onto e.g. `fr`/`zh`). Add a language = register one fn, no caller changes.
- `english.ts``lemmatizeEnglish(word)`: `IRREGULAR_GROUPS` (base→[forms], flattened to inflected→base at load) for suppletive verbs / irregular plurals / irregular comparatives, + regular suffix rules (plural -s/-es/-ies→y/-ves→f,fe/-ses→sis; past -ed/-d/-ied→y + de-double; -ing + e-restore + de-double + -ying→ie; comparative -er/-est/-ier→y; possessive `'s`; adverb -ly). ASCII-single-token guard `/^[a-z][a-z'-]*$/` (no-op on phrases/numbers/CJK/accented). Lowercases input; never returns the input itself or single letters.
**Key design insight: over-generate, let the dictionary validate.** The lemmatizer need not be linguistically precise — a bogus stem just misses and the loop moves on. So rules can be liberal. Cost is bounded: lemmas only fire AFTER exact+case all return empty (genuine "not a headword"), and the English rules produce ~25 candidates.
**Ordering gotcha**: `-ses→-sis` rule must come BEFORE generic `-es`/`-s` so `analyses``analysis` (the issue's expected noun) is tried ahead of `analyse` (the verb). Both are linguistically valid for `analyses`; issue wants the noun.
Tests: `__tests__/services/dictionaries/lemmatize/{english,index}.test.ts` + extended `lookupCandidates.test.ts` (all 8 issue cases asserted). Existing trim test's `spaced` sample swapped to `planet` (non-inflecting) since no-lang path now defaults to English lemmatization. Pure functions, fully deterministic — no live MDX needed. Related: [[dict-lookup-browser-hijack-4559]], [[wordlens-feature]].
@@ -1,17 +0,0 @@
---
name: dict-lookup-browser-hijack-4559
description: Android system-dictionary lookup landing in the OEM browser instead of Eudic/欧路 — package-visibility + PROCESS_TEXT browser hijack
metadata:
node_type: memory
type: project
originSessionId: 5e397668-b766-439c-873c-00ccb1da715a
---
#4559 (PR #4568): on VIVO/iQOO (OriginOS) the system-dictionary lookup opened `com.vivo.browser/.BrowserActivity` instead of an installed dictionary. TWO root causes, both in the Android half of `show_lookup_popover` (`tauri-plugin-native-bridge/.../NativeBridgePlugin.kt`):
1. **Package-visibility filtering (primary).** App is `targetSdk 36`, but the native-bridge manifest had NO `<queries>` for `ACTION_PROCESS_TEXT`. Under Android 11+ filtering, `queryIntentActivities(PROCESS_TEXT)` then returns only *auto-visible* apps — web browsers are auto-visible (web-intent exception), arbitrary dictionary apps (Eudic/欧路/GoldenDict) are NOT. So the query returned just the browser. Fix = add `<queries><intent><action PROCESS_TEXT/><data text/plain/></intent></queries>` to the plugin manifest (mirrors the native-tts `TTS_SERVICE` pattern). This alone is likely the whole user-visible fix.
2. **Browser hijack.** Even when visible, an OEM browser registering `ACTION_PROCESS_TEXT` can be the system default and swallow a plain `startActivity`. Fix = filter browsers out of the handler set in a pure `decideLookupDispatch(handlers, browserPackages, remembered)` (new `LookupDispatch.kt`, JUnit-tested): no-browser → implicit (unchanged, keeps native "Always"); browser+1 dict → explicit `setClassName` direct launch; browser+≥2 → `createChooser` + `EXTRA_EXCLUDE_COMPONENTS`; browser-only → `unavailable:true`. Browsers detected via `queryIntentActivities(ACTION_VIEW https + BROWSABLE)` (auto-visible, no `<queries>` needed).
**Remember-the-choice** (the maintainer wanted "smooth once chosen"): `ACTION_CHOOSER` has NO native "Always" button (mutually exclusive with `EXTRA_EXCLUDE_COMPONENTS` — the resolver that *has* Always can't exclude and obeys the browser default). Re-implemented Always: pass an `IntentSender` to `createChooser`; system returns `EXTRA_CHOSEN_COMPONENT` to a manifest `LookupChoiceReceiver` (exported=false; explicit intra-app PendingIntent so non-exported is fine; FLAG_MUTABLE on S+) which persists pkg/class to a plain SharedPreferences (`readest_lookup_dictionary_v1`). Next lookup fast-paths it. Reset UI: `get_lookup_dictionary`/`clear_lookup_dictionary` commands (build.rs COMMANDS + default.toml + autogenerated TOMLs/schema/reference regenerate on `cargo build -p tauri-plugin-native-bridge`) → Android-only conditional reset row in `CustomDictionaries.tsx`, only shown when something is actually remembered (`getRememberedLookupApp` returns null otherwise, so no clutter).
Maintainer DECLINED a settings picker to pre-pick a specific app ("we won't call the app directly"); browser-exclusion respects that (dynamic, not a user-set hardcode). Verified: gradle JUnit (7 cases) + vitest (12). `test:rust`/`clippy` were blocked by UNRELATED stale shared-`target/` cache (deleted sibling worktree `readest-feat-android-rangefile-protocol` path in `fs` plugin permission scan) — not my change; validated Rust via targeted plugin build. Related: [[android-open-with-intent-flow]], [[android-nativefile-remotefile-io]].
@@ -1,22 +0,0 @@
---
name: download-file-scope-android-regression
description: "#4639 strict is_allowed broke ALL Android downloads to app data dir (covers/dicts/books); fix = app.path() base-dir membership, not glob scope"
metadata:
node_type: memory
type: project
originSessionId: e78227be-6260-405a-88fb-48ffe4b20615
---
Regression from [[security-advisories-web-2026-06]] PR #4639 (commit 4025c4d7b). On Android every `download_file` into the app's own data dir failed: `permission denied: path not in filesystem scope: /data/user/0/com.bilingify.readest/Readest/{Books/<hash>/cover.png, Dictionaries/<id>/*.mdx, ...}`. The error string IS `transfer_file.rs Error::Forbidden` from `ensure_path_allowed`.
**Root cause (non-obvious):** `app.fs_scope().is_allowed(p)` returns **false** for the app's own files on Android. `FsExt::fs_scope()` returns the GLOBAL `state::<Scope>().scope`, but the capability scope patterns that cover the data dir (`$APPDATA/Readest/**/*`, `**/Readest/**/*`) are **command-scoped**, NOT in that global scope. The fs plugin's `resolve_path` (tauri-plugin-fs `commands.rs`) passes because it checks `fs_scope.scope.is_allowed(p) || scope.is_allowed(p)` where the 2nd `scope` is rebuilt from `global_scope.allows()+command_scope.allows()` per-command — that's where those patterns live. This is the SAME gap `dir_scanner::read_dir` works around with `|| contains("Readest")`. So #4639's note "Chose STRICT is_allowed (NOT read_dir's contains-Readest hatch)" was the bug — strict `is_allowed` rejects the app's own dir on Android.
Why fs-plugin writes work but `download_file` didn't: app writes via `baseDir: AppData` + relative path → `webview.path().resolve(rel, AppData)` → canonical form matched by the per-command scope. `download_file`/`upload_file` use raw `tokio::fs` with a JS-supplied ABSOLUTE path, so none of that applies.
**WRONG first attempt (don't repeat):** canonicalizing the symlink (`/data/user/0/<pkg>``/data/data/<pkg>`, since `is_allowed` only canonicalizes EXISTING paths and a download target doesn't exist yet) then re-calling `is_allowed`. Verified on-device it STILL fails — the patterns aren't in the global scope at ALL, so no path form matches. The canonicalize-existing-ancestor helper is still useful, just for the prefix check below, not for `is_allowed`.
**Shipped interim fix** (PR #4651, commit 75b469931): owner rejected the base-dir-membership version as over-engineered ("bloody hard coded" dir list) and asked to mirror `dir_scanner` instead. `ensure_path_allowed` = reject relative+`..` (`has_disallowed_components`) → `if app.fs_scope().is_allowed(p) || is_within_app_storage(p) Ok`. `is_within_app_storage(file_path, app_identifier) = file_path.contains("Readest") || file_path.contains(app_identifier)` where `app_identifier = &app.config().identifier` (NOT a hardcoded literal; `config()` is inherent on AppHandle — NO `use tauri::Manager`). The bundle id (`com.bilingify.readest`) is in EVERY Android sandbox path incl. the cache dir, so it closes the OPDS-to-`$APPCACHE` gap that `contains("Readest")` alone misses (`Readest` is the `DATA_SUBDIR`, capital-R; cache dir has only the lowercase bundle id). `..` rejection keeps GHSA-55vr-pvq5-6fmg (`~/.ssh/id_rsa` has neither marker). Substring posture = same as `dir_scanner`. **Follow-up (deferred):** replace the substring fallback with `BaseDirectory`+relative resolved via `app.path()` (the app already has `appService.resolvePath()``{baseDir, fp}`; callers currently flatten it to an absolute string) so targets are in-scope by construction.
(Rejected earlier attempt, kept for context: base-dir-membership via `app.path()` dirs + `is_inside_any` canonicalizing both sides for the `/data/user/0``/data/data` symlink. Correct but owner found the dir enumeration ugly.)
**On-device verify recipe (Xiaomi fuxi 2211133C, real release-signed devtools build):** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools && adb install -r .../app-universal-release.apk`. Local keystore (`gen/android/keystore.properties``/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`, alias `upload`) matches installed signer → `-r` PRESERVES user data (dicts/books). CDP probe: `adb forward tcp:9333 localabstract:webview_devtools_remote_<pid>` (socket name = app pid; stale sockets linger — pick the one matching `adb shell pidof`); fetch `/json/list` via node http (NOT curl — mishandles WebView framing); Node v24 has global `WebSocket`. Raw `invoke('download_file',...)` needs a Channel for `on_progress`: pass `{ ['__TAURI_TO_IPC_KEY__']: () => '__CHANNEL__:'+I.transformCallback(()=>{}) }`. Result: in-scope `/data/user/0/.../Readest/x.bin` → OK; `/data/local/tmp/evil.bin` → still Forbidden. `appData=/data/user/0/com.bilingify.readest` (no `/files`); appCache/temp=`.../cache`.
@@ -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,23 +0,0 @@
---
name: edge-tts-word-highlighting-4017
description: "Edge TTS word-by-word highlighting (#4017, PR"
metadata:
node_type: memory
type: project
originSessionId: afd9b381-c17d-4988-b287-07263d8bea0b
---
# Edge TTS word-by-word highlighting (#4017, PR #4566)
**Design: keep sentence granularity, add word highlight on top.** All clients still report `getGranularities() = ['sentence']` — switching foliate to word marks would regress media-session metadata (one word on lock screen), byMark seek (word steps), `getSpokenSentence`, and per-word synthesis. Instead: `EdgeSpeechTTS.createAudio()` returns `{url, boundaries}` (cached per payload-hash next to the blob URL), `EdgeTTSClient` runs a rAF loop syncing `audio.currentTime` (media time → playbackRate/pause-safe) against boundary ticks, and `TTSController.prepareSpeakWords/dispatchSpeakWord` match words sequentially (`indexOf` with a moving cursor; unmatched word = skip WITHOUT advancing cursor) against the sentence range text, then highlight the sub-range via the existing `#getHighlighter`.
**Edge wire facts** (verified with raw WS probe + live):
- `audio.metadata` frames: `{"Metadata":[{"Type":"WordBoundary","Data":{"Offset":1000000,"Duration":4250000,"text":{"Text":"Dr.","Length":3}}}]}` — one word/frame, ticks = 100 ns (1e7/s), offsets relative to this request's audio stream.
- `Text` is the **verbatim input span** ("Dr.", "23", "$5.50" keep punctuation; trailing sentence punctuation stripped) → sequential indexOf matching is robust. Works for zh too.
- The readaloud endpoint gates on **User-Agent (needs Edg/non-headless), NOT Origin** — a localhost Origin with Edg UA is accepted; default HeadlessChrome UA is rejected (close 1006).
**Pre-existing bug fixed in the same PR:** browser branch did `new WebSocket(url, {headers})` → native WebSocket parses the object as a subprotocol → `SyntaxError` → on web the wss path could NEVER work (always https-proxy fallback, which strips boundaries). Node-only options now.
**Probe gotchas:** Overlayer draws the highlight as a `<path>` inside `<g fill="#808080">` (NOT `<rect>` — rect-only DOM probes miss it); the overlayer svg lives in `FOLIATE-PAGINATOR`'s open shadow root (sibling layer of the iframe, not inside it). TTS auto-advance creates new views — re-query svgs per sample, never cache the list.
**dev-web live-verify recipe:** gstack `browse --proxy http://127.0.0.1:8118` (flag needed on EVERY invocation; this machine's external net needs the local proxy, headless Chromium doesn't inherit it) + `browse useragent '...Edg/143...'` (context-level, doesn't break Next) — do NOT use `browse header Origin:...` (extra headers hit localhost too → Next dev 403s ALL chunks → blank page; headers can't be removed without daemon restart). Import books via synthetic drop: fetch epub from `public/`, `DataTransfer` + `DragEvent('drop')` on `.library-page` (in-memory only — re-import after every reload/restart). Patch `content.overlayer.add/remove` to log the real highlight calls — the ground truth when screenshots race. Related: [[tts-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,14 +0,0 @@
---
name: feedback-commit-message-english-only
description: "Commit messages (and PR titles) must be English-only — no CJK characters, no em/en dashes"
metadata:
node_type: memory
type: feedback
originSessionId: c0199d69-f314-45ee-bf7c-867b908641cc
---
Git commit messages must be **English only**: no CJK characters (no 中文/量词/example glyphs like 第一封信) and no em/en dashes (— ). Use plain ASCII punctuation (comma, colon, parentheses, `...`). The same applies to PR titles for consistency.
**Why:** the user (a maintainer of readest/readest) keeps the project's git history English-only and clean.
**How to apply:** when a fix is about Chinese/CJK text, describe the concept in English in the commit subject/body (e.g. "measure-word prose", "the classifiers for 'letter' and 'book'") instead of pasting the glyphs. Keep the concrete CJK examples and screenshots in the PR *body* / code / tests, where they aid understanding — that is fine. First seen on PR #4660 ([[txt-chapter-measure-word-4658]]), where "量词" in the subject had to be amended to "measure-word".
@@ -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,20 +0,0 @@
---
name: footnote-aside-namespace-order-4438
description: Footnote aside border line regression — @font-face inlined before @namespace invalidated the namespaced footnote-hiding selector
metadata:
node_type: memory
type: project
originSessionId: 788943e6-fede-4c8f-828c-695ca873f178
---
#4438 (v0.11.4 regression): a stray horizontal line appeared below the footnote/annotation marker because the footnote `<aside epub:type="footnote">` (with the book CSS's `border:3px #333 double`) stopped being hidden.
**Root cause:** PR #4383 (`e8675fb7e`, inline custom @font-face) changed `getStyles` assembly in `src/utils/style.ts` from `${pageLayoutStyles}...` to `${customFontFaces}\n${pageLayoutStyles}...`. The `@namespace epub "..."` declaration lived *inside* `getPageLayoutStyles`. Per the CSS spec a `@namespace` rule is honored ONLY if it precedes every style/`@font-face` rule — a misplaced one is silently ignored. The inlined `@font-face` rules pushed `@namespace` down, invalidating it, so the namespaced selector `aside[epub|type~="footnote"] { display:none }` was dropped and the aside border showed. Only hit users **with custom fonts loaded** (otherwise `customFontFaces` is empty and `@namespace` stayed first).
**Fix:** Hoist `@namespace` to the very front of the assembled stylesheet (`const epubNamespace = '@namespace epub "..."'; return \`${epubNamespace}\n${customFontFaces}\n${pageLayoutStyles}...\``) and remove it from `getPageLayoutStyles`. Custom faces still precede the `--serif`/`--sans-serif` lists, preserving #4383's first-paint intent.
**Gotchas verified the hard way:**
- `epub:type` is a *namespaced* attribute only when the doc is parsed as XHTML/XML (foliate loads EPUB content as XHTML). Playwright `page.setContent` parses as **HTML**, where `epub:type` is a plain attr and `[epub|type~=...]` never matches — repro must use `data:application/xhtml+xml` via `page.goto`.
- The existing test `style-get-styles.test.ts` literally asserted the buggy order (`@font-face` before `@namespace`) on the false premise that an @font-face must precede the font-family rules that use it. It must not — @font-face rules are collected regardless of source position; only same-family redefinition cares about order.
Related: [[css-style-fixes]], [[table-dark-mode-tint-4419]] (both in the bug-prone `style.ts`).
@@ -1,19 +0,0 @@
---
name: global-annotation-pageturn-perf-4575
metadata:
node_type: memory
type: project
originSessionId: 4640857e-9c37-4ec6-890a-8aa20ec3a3f3
---
**#4575** — "after highlighting several main-character names, page turning is very laggy" (Chinese web-novel TXT). Root cause = the **global highlight** feature (highlight-all-occurrences, `note.global`), NOT plain highlights. The `progress` effect in `Annotator.tsx` (`for (const a of annotationIndex.globals) expandAllRenderedSections(view, a)`) re-fans-out EVERY global note across EVERY rendered section on EVERY page turn. Each pass: TreeWalker over the section DOM (`findTextRanges` in `globalAnnotations.ts`) + `view.getCFI(index, range)` per occurrence (~0.2ms each, the dominant cost) + `overlayer.add` which removes+recreates an SVG and calls `getRects` (forces layout). Overlays already exist after pass 1 → pure waste.
**Profiled live** (dev-web + claude-in-chrome, real `<foliate-view>`): 6 names / 226 occurrences across 2 rendered chapters = **~2545ms synchronous main-thread per page turn** on desktop (×35 on mobile = the lag). Leads 姜窈(73×) 驰厉(66×) per 2 chapters.
**Fix (PR branch `fix/global-annot-pageturn-4575`, commit f1404c6b1):** module-level `WeakMap<Document, Map<noteId, signature>>` `expandedByDoc` in `globalAnnotations.ts`. `expandGlobalAnnotation` skips when `docMemo.get(note.id) === signature`; records after expanding (even 0 matches). `signature = updatedAt:style:color:text`. `removeGlobalAnnotationOverlays` clears the memo entry. Turns 2..N → ~0ms; one-time cost stays at section-render (`onCreateOverlay`). 5 unit tests in `src/__tests__/utils/global-annotations.test.ts`.
**Correctness invariant:** `doc` & `overlayer` are created/destroyed together per section content-record, and `getContents()` returns STABLE doc/overlayer refs across separate calls (verified). So "same doc ⟺ overlays still present" — a re-rendered section gets a fresh `doc` → memo miss → re-expand; never wrongly skips.
**Secondary complaints in the issue (NOT fixed here):** slow TXT import (`txt.ts` parse, "2MB should be instant"), slow TOC/notes open. Separate concerns.
**Repro recipe — import a GBK TXT into dev-web without the native picker:** copy the .txt into `public/`, then in-browser `fetch('/file.txt')``arrayBuffer``new File([buf], '原名.txt')``DataTransfer` → dispatch a synthetic `drop` `DragEvent` (with `Object.defineProperty(ev,'dataTransfer',{value:dt})`) on `.library-page`. Raw bytes preserved → app's encoding detection handles GBK. Books at `/Users/chrox/Documents/books/issues/4575/`. See [[tts-sync-chrome-verification]] for the live-foliate-view profiling pattern.
@@ -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,18 +0,0 @@
---
name: inline-block-column-overflow
description: Foliate paginator fix — atomic-inline (inline-block) boxes too tall to fragment clip content in paginated mode
metadata:
node_type: memory
type: project
originSessionId: f0c35f7b-d4ff-4275-9f13-7019b0e167d9
---
Bug: a chapter's content jumps straight to its "Reference materials", silently skipping a large middle (deep-dive/wrap-up). Repro book: "System Design Interview Vol.2" (System Design EPUB), Chapter 8 `OEBPS/c554.xhtml`, reported "at page 346".
Root cause: the EPUB's own CSS wraps the whole chapter body in a `<div>` with `display: inline-block` (`.class_s5mz1`). Atomic inline-level boxes (inline-block / inline-flex / inline-grid / inline-table) **cannot fragment across CSS columns**, so in paginated (columnized) mode the 7700px-tall box overflows the page vertically and every column past the first is clipped → "1 page left in chapter" while most of the chapter is unreachable. Direct `goTo({index})` and forward `next()` both still RENDER the section (engine traverses by content), so the symptom only manifests as clipped/unreachable pages + bogus page counts; scrolled mode is unaffected (vertical overflow is normal there).
Diagnosis tell: in column mode `documentElement.scrollHeight >> clientHeight` (e.g. 7768 vs 632); late headings stack vertically at one far-right column-left offset instead of spreading across columns.
Fix: `packages/foliate-js/paginator.js``#demoteUnfragmentableBoxes(availableHeight)`, called from `columnize()` after `setImageSize` (column-mode only). Guarded fast-path: returns immediately unless `scrollHeight > clientHeight + 1`. When overflowing, scans `body.querySelectorAll('*')`, and for any element whose computed display is atomic-inline AND `getBoundingClientRect().height > availableHeight`, demotes to the fragmentable equivalent (inline-block→block, inline-flex→flex, inline-grid→grid, inline-table→table) via `setStylesImportant`. Idempotent (demoted elements no longer match), regression-free (short legit inline-blocks like side-by-side figures untouched — they're never page-tall). Mirrors the existing `setImageSize` over-tall-image clamp and the `p { display: block }` rule in `style.ts` ("epubs set insane inline-block for p").
Test: `src/__tests__/document/paginator-inline-block-overflow.browser.test.ts` + fixture `repro-inline-block-overflow.epub` (one chapter wrapped in `.wrap{display:inline-block}`, 50 paras + TAIL_MARKER). Asserts `scrollHeight <= clientHeight+2`, wrap display `block`, tail heading in a later column within page height. Needs real layout → browser test (jsdom has no layout). Dev server (Next/Turbopack) picks up the workspace foliate-js edit on reload. Related: [[paginator-gutter-bleed-asymmetry-4394]].
@@ -1,18 +0,0 @@
---
name: ios-instant-dict-double-popup
description: iOS instant system-dictionary fired 2-3× per long-press + tap-to-deselect re-opened it + Word Lens ignored system dict; deferredAction once-per-gesture latch + long-press-hold gate
metadata:
node_type: memory
type: project
originSessionId: 1c09f918-0b1d-4f75-b1c6-8cef5eb73d60
---
Three related instant-dictionary bugs fixed together (dev branch, 2026-06-18). Core: the **instant quick action** (Annotator effect `[selection,bookKey]``handleQuickAction`) fired per `selectionchange`, and **iOS emits MULTIPLE `selectionchange` for one long-press** (user log showed 3; Android emits 1). Each fire → `handleDictionary` (system path) → `invokeSystemDictionary` → native `show_lookup_popover` which drills to the top-most presented VC and stacks another `UIReferenceLibraryViewController` → 2-3 sheets. Android never hit it because it **defers the action to `touchend`** (coalesces); iOS fired immediately.
**Fix 1 (double/triple sheet)** — `src/app/reader/utils/deferredAction.ts`: added a `fired` latch so `runOrDeferAction`/`flushDeferredAction` run the action **at most once per gesture**; `beginGesture(state)` (clears pending + re-arms) called at gesture start — Android **native** `touchstart` (replaced `cancelDeferredAction`) and a NEW **non-Android DOM `pointerdown`** listener in `Annotator.tsx` (gated `!isAndroidApp`; Android keeps its native path).
**Fix 2 (tap-to-deselect re-opened dict ~1/3)** — after dismissing the sheet iOS leaves the word selected; the reselection is safe (latch still set, no WebView pointerdown from the modal swipe), but tapping outside to deselect IS a pointerdown → `beginGesture` re-armed the latch, then a racy `selectionchange` re-reported the lingering word before collapse → re-fired. Fix = `isLongPressHold(pointerDownTime, now, 300ms)` gate in `handleQuickAction` (gated `!isAndroidApp`): only fire from a long-press hold (iOS selection appears ~500ms after pointerdown; tap-stray fires ~tens of ms). Touch pointerdown time recorded in the non-Android listener; **mouse records 0 → bypasses the gate** (desktop selects on pointerup).
**Fix 3 (Word Lens ignored system dict)** — Annotator effect `wantWordLensDict` branch hardcoded `setShowDictionaryPopup(true)`; changed to call `handleDictionary()` (which checks `isSystemDictionaryEnabled``invokeSystemDictionary`, else in-app popup), same as the toolbar/instant paths. See [[wordlens-feature]].
Verified on Xiaomi 13 Pro via CDP+adb (`src/__tests__/android/helpers/*`): real system-dict path on Android = `ACTION_PROCESS_TEXT`→Eudic; instant dict fired once/long-press + re-armed gesture 2; gloss tap → `handleDictionary system=true`+`invokeSystemDictionary os=android`. iOS confirmed by user. Gotchas: `longPressWord` waits for a persistent selection → times out in instant-action mode (the action dismisses the selection); count fires via a `console.info` hook read over CDP `evaluate` instead. `openFixtureBook`'s >200-char gate fails when the fixture's saved progress lands on the sparse feedbooks end-page → connect to the already-open reader instead.
@@ -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,48 +0,0 @@
---
name: issue-4584-tap-death-investigation
description: "#4584 single-taps-dead-after-picture-zoom: isPopuped self-heals (red herring), likely WebView-148-specific, plus Android emulator/CDP gesture-verification gotchas"
metadata:
node_type: memory
type: project
originSessionId: a41b6cab-c0f3-4740-a4c0-61a10b68fc09
---
#4584 (Android): after using picture zoom (long-press image → ImageViewer → close),
single taps stop registering until app restart while long-press keeps working. OP on
Android 16 / WebView 148. **NOT root-caused / NOT fixed.** PR #4600 only adds single-tap
as a second way into the viewer (see [[tap-to-open-image-table-4600]]); it does not fix
the tap-death.
**Red herring — don't "fix" `isPopuped`.** An adversarial analysis pointed at
`useTextSelector.ts` `handleSingleClick`'s `isPopuped` branch consuming a tap without
resetting the flag (unlike its `isTextSelected`/`isUpToPopup` siblings). But it
**self-heals**: the branch calls `handleDismissPopup()``showingPopup` false →
`handleShowPopup(false)``isPopuped` false after 500ms. And the uncancelled 500ms
timer in `handleShowPopup` always converges `isPopuped` to the FINAL `showingPopup`
(timers are same-delay FIFO), so it can't strand `isPopuped=true` with `showingPopup=false`.
Net effect is at most a one-tap glitch, NOT permanent. Resetting it in the branch is
harmless but does NOT explain #4584.
Likely **WebView-148-specific**: the OP reproduces; the maintainer's Xiaomi and the
Pixel_9_Pro emulator (WebView **133**) do NOT. A stronger unverified permanent-death
candidate is `isInstantAnnotating`/`scrollLocked` getting stuck when the ImageViewer
overlay swallows the `pointerup` (needs instant-annotation enabled).
**Android emulator gesture-verification gotchas (this machine):**
- Host-GPU `Pixel_9_Pro` AVD CRASHES on rapid pinch (Vulkan `bad_function_call`); a
re-launch can hang at Vulkan init. `-gpu swiftshader_indirect` boots reliably but is
too slow for the WebView reader → repeated "Input dispatching timed out for
FocusEvent" ANRs under automation. **That ANR is an EMULATOR ARTIFACT, not app code**
(JS stays responsive; CPU runs native arm64 on Apple Silicon, only the GPU is
swiftshader; CPU profile of page-turns is light; full-screen backdrop-blur made zero
frame-time difference).
- CDP `Input.dispatchTouchEvent` CANNOT trigger the WebView's native text-SELECTION
gesture, so the annotation popup never appears (blocks reproducing selection-driven
bugs). It also leaves `screenX=0`, so `handlePageFlip` treats every synthetic tap as
left/`prev()` (no-op at page 1 → navigate mid-book to test page-turns via relocate).
- Native `adb shell input tap` does NOT reach the WebView's click pipeline.
- Long-press via CDP only works if you HOLD the touch on one open connection and poll
(a touchStart→sleep→touchEnd across separate `node` invocations releases too early).
- Drive the live on-device WebView: `adb forward tcp:9222 localabstract:webview_devtools_remote_<pid>`
+ a `ws` CDP client; the debug APK exposes the socket. VIEW-intent a pushed EPUB via its
MediaStore `content://.../<id>` to import it.
@@ -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,18 +0,0 @@
---
name: koplugin-note-deletion-sync
description: "koplugin deleting a note didn't sync to Readest — push only walked live annotations; fixed with persisted tombstones"
metadata:
node_type: memory
type: project
originSessionId: f3f6e25e-1bde-4e08-9fc9-c8184d1ef457
---
readest.koplugin deletions of highlights/bookmarks never reached the server (#4119 push direction; the pull direction was already handled by `removeDeletedAnnotations`).
**Root cause:** `SyncAnnotations:push` builds its payload from `getAnnotations`, which walks `ui.annotation.annotations` (the *live* list). A deleted note is already removed from that list, so it can never appear in a push — the server never gets a `deleted_at`, and the next pull resurrects it.
**Fix (all in `apps/readest.koplugin/`):**
- `readest_syncannotations.lua`: extracted `buildNoteDescriptor(item, book_hash, meta_hash)` (shared by `getAnnotations` and the new deletion path). Added `recordDeletion(doc_settings, item)` — stamps `deletedAt = os.time()*1000` on the descriptor and persists it under `doc_settings.readest_sync.deleted_notes` (per-book sidecar, survives restart/offline). `push` folds `deleted_notes` into the payload (re-stamping current book/meta hash) and clears them in the success callback only (failed push retries).
- `main.lua` `onAnnotationsModified(items)`: detects a removal via `items.index_modified < 0` (additions use positive index_modified; note-text edits / type-changes carry no index_modified — see KOReader `ReaderBookmark:removeItemByIndex`). Records the tombstone when `access_token` is set (independent of `auto_sync`, so a later manual/close push still carries it).
**Key facts:** note id is deterministic — server-created notes keep their stored `item.id` (pulled in), koplugin-created ones derive `generateNoteId(hash, type, pos0, pos1)`; both match the server row, so the tombstone targets it. Server `sync.ts` POST picks the tombstone via `clientDeletedAt > serverDeletedAt` (deletedAt wins even when updatedAt is stale). Missing `text`/`note` in the payload is fine (`sanitizeString(undefined)` returns undefined; plain highlights already push `note=nil`). Tests in `spec/syncannotations_spec.lua` (recordDeletion + push-folds-tombstones, UI-glued push driven by stub client/doc_settings). Related: [[custom-fonts-reincarnation-4410]] (CRDT remove-wins).
@@ -1,27 +0,0 @@
---
name: koplugin-stats-sync
description: KOReader readest.koplugin reading-stats sync (push on close / pull on open); 3-bug chain fixed in PR
metadata:
node_type: memory
type: project
originSessionId: 9b3eaf27-8688-4293-ab42-f635af2e1905
---
`readest.koplugin` reading-statistics sync (KOReader `statistics.sqlite3` page events) — fixed in PR #4666 (`readest_syncstats.lua`, `main.lua`, `readest-sync-api.json`).
**Trigger model (fully automatic, no menu/gesture):**
- **Pull** on book OPEN: `onReaderReady``pullBookStats(false)` (via `nextTick`).
- **Push** on book CLOSE: `onCloseDocument``pushBookStats(false)` (wrapped in `goOnlineToRun`).
- Both gated on `self.settings.auto_sync and self.settings.access_token`, `interactive=false` (silent on failure).
- NOT per-book: `collectSince` queries the whole `statistics.sqlite3` (`page_stat_data JOIN book`, no book filter). Open/close is just the trigger; it syncs the entire stats delta. Incremental via `stats_push_cursor` (max `start_time`) / `stats_pull_cursor` (max `updated_at_ms`); cursor advances only on full success.
**3 stacked bugs (each hid the next):**
1. `push`/`pull` called `settings:readSetting/saveSetting` on `self.settings`, which is the PLAIN `readest_sync` data table (from `G_reader_settings:readSetting("readest_sync", default)`), NOT a `LuaSettings` object → `attempt to call method 'readSetting' (a nil value)` on every open. Fix: field access + persist via `G_reader_settings:saveSetting("readest_sync", settings)` (mirrors `readest_syncauth.lua`). Field-access pattern is used everywhere else (`self.settings.access_token`, etc.).
2. `pushChanges` requires `books/notes/configs` (`required_params`); stats sent only `statBooks/statPages` → Spore `books is required`. Fix: send empty `books={},notes={},configs={}` alongside. Server (`src/pages/api/sync.ts`) defaults each to `[]` and processes `statBooks`/`statPages` independently (gated only on their own `.length`).
3. `statBooks/statPages` were in the spec's `payload` but not `optional_params` → Spore `statBooks is not expected`. **Spore's expected-param set = `required_params optional_params`; `payload` only controls body serialization, NOT acceptance** (`common/Spore/Request.lua` `validate()`). Fix: add `optional_params:["statBooks","statPages"]` to `readest-sync-api.json`. Must be optional not required (library/config/annotation pushes legitimately omit them).
**Spore client:** `readest_syncclient.lua` `_dispatch` runs the RPC in a coroutine with Turbo `AsyncHTTP` (network is non-blocking, yields to event loop); `Format.JSON` encodes the body synchronously first. `SYNC_TIMEOUTS={5,10}` (block/total).
**Large-backlog blocking risk (UNFIXED, potential follow-up):** push sends the WHOLE backlog in one request (no client chunking; only pull is paginated). For ~10k `page_stat_data` rows: `collectSince` builds a 10k-entry Lua table + ~1MB JSON encode SYNCHRONOUSLY on the main thread → ~1-2s UI stall on weak e-ink CPUs at book close (network part is async, doesn't freeze). Worse: 10s timeout + no chunking + all-or-nothing cursor → if it can't finish in time it fails silently, cursor stays 0, and every later close re-collects/re-encodes/re-uploads the same backlog (server upserts are idempotent but wasteful). Fix idea: chunk push (~500-1000/req, matching server `BATCH=500`), advance cursor per successful chunk.
**Testing gap that hid it:** `spec/syncstats_spec.lua` originally tested only `collectSince`/`applyRemote`, never `push`/`pull`. Added tests: cursor advance on success (mock client enforces `required_params`), pull cursor from newest `updated_at_ms`, and a spec-level check parsing `readest-sync-api.json` to assert every stats-push key is an expected param (reproduces bug 3). Busted runs with `cwd=KOPLUGIN_DIR` so `io.open("readest-sync-api.json")` works; `require("json")`→dkjson via spec_helper shim. Debug logging kept (`ReadestStats` prefix). Related: [[koplugin-note-deletion-sync]], [[kosync-cfi-spine-resolution]].
@@ -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,18 +0,0 @@
---
name: nightly-updater-android-e2e
description: "How to E2E-test the nightly self-updater on a real Android (Xiaomi/HyperOS) device — devtools build, CDP-over-adb, MIUI install gates"
metadata:
node_type: memory
type: reference
originSessionId: b0c01e3c-9485-45fe-8ae3-eb5f2762f8fa
---
End-to-end validating the in-app nightly updater (#4577) on the physical Xiaomi 13 (`fuxi`, model 2211133C, HyperOS V816 / Android 16, arm64). Verified the full chain: stable `0.11.4` on nightly channel → fetch `nightly/latest.json` → detect newer nightly → dialog+changelog → download APK → minisign-verify → Android PackageInstaller → app relaunches as `0.11.4-2026061506` with user data intact.
**Get CDP on a release build:** `pnpm dev-android` = `tauri android build -t aarch64 -- --features devtools` then `adb install -r`. The `devtools` cargo feature enables WebView remote debugging; the **CI nightly has no such flag → no CDP socket**. The local build is release-signed via `src-tauri/gen/android/keystore.properties` (alias `upload`, keystore at `/Users/chrox/dev/Android/keys/upload-readest-keystore.jks`), cert SHA-256 `652d1167…` — SAME as the CI release/nightly cert, so it installs over (and is replaced by) the real nightly. versionCode for `0.11.4[-stamp]` is always `11004` (Tauri ignores the prerelease), and sideload allows equal versionCode. After the updater installs the real nightly, CDP is gone again (no devtools).
**Comparator (`utils/version.ts` `isUpdateNewer`):** on equal X.Y.Z, a nightly outranks the matching stable (`c.isNightly && !cur.isNightly → true`). So a stable build on the nightly channel IS offered the latest nightly — no need to stamp an older version. Two nightlies compare by the 10-digit stamp.
**CDP-over-adb gotcha:** `adb forward tcp:9333 localabstract:webview_devtools_remote_<PID>` (use the LIVE pid — stale sockets for dead pids linger in `/proc/net/unix`). The WebView's `/json` HTTP server breaks BOTH curl and Node `http` (framing → "empty reply"/"socket hang up"); fetch the page list over a RAW TCP socket instead, and build the ws URL yourself as `ws://127.0.0.1:9333/devtools/page/<id>` (the returned `webSocketDebuggerUrl` reflects the request Host header and drops the port). Then `Runtime.evaluate` works. Helper pattern lived in `/tmp/nightly-test/cdp.cjs`. Settings store is NOT on `window`; drive the real UI via evaluated `.click()` (Settings Menu → "Nightly Builds (Unstable)" toggle → "About Readest" → "Check Update").
**MIUI/HyperOS install gates (the hard part; needs adb taps + uiautomator, NOT WebView):** (1) "Readest 正尝试安装应用" → tap 继续. (2) "Couldn't find ICP registration info" (China-region nag) → tap **Install** (left grey), NOT the blue Exit. (3) Enhanced-protection installer shows NO direct install button — the visible "安装" is the AD app's (`installBtn`, `com.aliyun.tongyi` etc. — don't tap it); the real path is top-right **More (⋮) → "单次安装授权"** (one-time auth), then the bottom **OK**. "Security authorization → Authorize unverified apps" uses Face-unlock as the verification method (per-install biometric; single-install-auth covered it here). Use `uiautomator dump` to get exact button bounds — native dialogs are introspectable (unlike the WebView). Pin every adb cmd with `ANDROID_SERIAL` when an emulator is also attached; zsh doesn't word-split `$ADB` vars (use the env var or inline the full path).
@@ -1,40 +0,0 @@
---
name: notch-mask-texture-4486
description: "Scrolled-mode top inset mask occluded the bg texture; clip-path full-cell trick aligns the mask's texture tiles with the viewer's; CDP-inject + MAE seam verify"
metadata:
node_type: memory
type: project
originSessionId: 47d2276d-0e04-455c-99b5-4fd0a651b579
---
#4486 (PR #4563): in scrolled mode the `notch-area` in `SectionInfo.tsx` masks the top
safe-area inset with opaque `bg-base-100` at z-10 (hides content scrolling under the
status bar) — and painted over the texture (`.foliate-viewer::before` lives at the z-0
paint layer, see [[paginated-texture-occlusion-4399]]). Flat untextured strip across the
unsafe header area.
**Fix pattern (paint-box matching).** A texture `::before` only tile-aligns with the
viewer's when `background-size: cover/contain` resolves against the SAME element box.
So: make the mask span the grid cell (`inset-0`) and clip the visible+hit area to the
strip with `clip-path: inset(0 0 calc(100% - topInsetPx) 0)`; add `.notch-masked::before`
to the selector group in `styles/textures.ts`, gated by a conditional class only in
scrolled-horizontal mode (paginated/vertical notch is transparent — texturing it would
double-texture over the viewer's). `mix-blend-mode: multiply` blends against the mask's
own opaque bg inside its z-10 stacking context → identical color math to the viewer area.
clip-path clips hit-testing too, so the click target stays the strip (verified with
`elementsFromPoint`: notch present in stack inside strip, absent mid-screen).
**Still texture-unaware** (same flaw, not yet reported): the vertical scrolled-mode side
masks in `BooksGrid.tsx` (`bg-base-100 absolute left-0/right-0 h-full` when
vertical+scrolled). Same trick applies if reported.
**Verification technique (no rebuild).** Drive the installed app on the device via
adb+CDP ([[cdp-android-webview-profiling]]) and inject the exact CSS artifacts the fix
produces (patch the `#background-texture` style text + classList/style edits — React
won't wipe manual DOM edits unless its computed className/style prop string changes
between renders). Quantify the seam: `magick compare -metric MAE` between adjacent
1px rows across the boundary — buggy hard seam was MAE 11913 (16-bit scale), fixed
seam 230 ≈ ordinary texture-row variation. Gotchas: adb taps at status-bar y-coords are
consumed by SystemUI (never reach the app) — use elementFromPoint or in-page click
counters for hit-testing; a mid-screen tap toggles the header/footer chrome which then
sits ABOVE the notch and pollutes hit stacks (toggle it off before probing).
@@ -1,18 +0,0 @@
---
name: opds-firefox-strict-xml-4479
description: OPDS feeds fail on Firefox but work on Chrome — strict DOMParser parsererror on junk after root; parseOPDSXML recovery
metadata:
node_type: memory
type: project
originSessionId: 9539d003-7df3-4643-99ca-bdee69be1b3f
---
#4479 (MEK catalog `bookserver.mek.oszk.hu`, a PHP backend `teljes.php`): OPDS feeds load on Chrome but on Firefox clicking anything shows loading then silently navigates back. Root cause: the server emits a valid Atom feed followed by **trailing junk after `</feed>`** (a stray PHP warning / extra tag / text). Chrome's `DOMParser` ignores it; **Firefox's strict parser replaces the WHOLE document with a `<parsererror>`** ("junk after document element" / "text data outside of root node", Mozilla namespace `http://www.mozilla.org/newlayout/xml/parsererror.xml`). The code then sees a non-`feed` root → treats response as HTML → finds no OPDS link → `router.back()`.
**jsdom mirrors Firefox exactly** (same strict behavior + same parsererror namespace), so this reproduces in vitest — no need for a real browser. Detect via `doc.documentElement.localName === 'parsererror' || doc.getElementsByTagName('parsererror').length > 0`. Leading whitespace before the root is VALID XML (not the issue); trailing non-whitespace is the killer.
**Fix:** `parseOPDSXML(text)` helper in `src/app/opds/utils/opdsUtils.ts` — parse, and on parser error re-parse the slice from the first element start tag (`/<([A-Za-z_][\w.:-]*)/`) to its last matching `</root>` close tag (drops leading prolog + trailing junk). Returns the original error doc if recovery fails (no regression — falls through to existing HTML branch). Wired into all 3 OPDS XML parse sites: `page.tsx` (reader nav), `validateOPDSURL` (opdsUtils, adding catalog), `feedChecker.ts` (subscriptions/auto-download). feedChecker also had the #4181 detection bug — switched `text.startsWith('<')``looksLikeXMLContent(text)` (the MEK feed has ~13 leading newlines + no `<?xml?>` decl), else the parse fix is unreachable there.
This is the same MEK server family as [[empty-start-cfi-sync]]-style "tolerate broken servers" work; related #4181 = leading-whitespace detection (`looksLikeXMLContent`).
NOT fixed (separate, out of scope unless asked): #4479 also reports Android **download** failures (acquisition links point to `mek.oszk.hu/.../*.epub`; works in plain browser, red "download failed" toast in app) — distinct from the XML parse issue.
@@ -1,46 +0,0 @@
---
name: opds-html-description-4503
description: "OPDS publication descriptions showed raw HTML tags; double-escaped type=\"text\" summaries + unsanitized innerHTML; fix decodes-if-fully-escaped then sanitizes"
metadata:
node_type: memory
type: project
originSessionId: 73fd2a21-ea89-4cdb-bbbd-23256a6ae5a2
---
Issue #4503 (FR, but a real bug): OPDS publication detail descriptions rendered
raw HTML — literal `<p>`, `</p>`, `&quot;`, `&#x27;` text — while Thorium renders
them. Reporter's feed served the same Gutenberg book (51726) via an aggregator.
**Root cause** (confirmed empirically by running foliate `getPublication` in jsdom):
the aggregator serves the description as an Atom `type="text"` `<summary>` whose
HTML was escaped *twice*. foliate's `getContent` (`packages/foliate-js/opds.js`)
only un-escapes for `type="html"`/`"xhtml"`; for `text` it returns `textContent`
verbatim, so the value stays `"&lt;p&gt;...&amp;quot;Wall&amp;quot;..."`.
`PublicationView` then dumped `content.value` straight into
`dangerouslySetInnerHTML` (also **unsanitized — an XSS sink** for arbitrary
remote feeds). The browser decodes one entity level → shows the still-escaped
`<p>`/`&quot;` as literal text. Single-escaped `type="text"` renders fine (value
already has real tags); only *double*-escaped breaks. type=html/xhtml render fine.
**Fix:** new helper `src/app/opds/utils/opdsContent.ts` `getOPDSDescriptionHtml()`:
decode one extra entity level **only when the value is entirely escaped markup**
(`/&lt;\/?[a-z]/i` present AND no real `/<[a-z]/i` tag — so mixed content like
`<p>see &lt;code&gt;</p>` is left literal), then `sanitizeHtml()` (the shared
DOMPurify sanitizer in `@/utils/sanitize` — generic, reused; note its
ALLOWED_TAGS has no `div`, so xhtml's `<div xmlns>` wrapper is unwrapped,
harmless). Wired into `PublicationView` via `useMemo`. Decode-then-sanitize
order matters: scripts hidden behind double-escaping are still stripped.
(PR #4510 also moved `sanitizeHtml`/`sanitizeForParsing` out of
`services/send/conversion/` into `@/utils/sanitize`, alongside `sanitizeString`.)
Exported `OPDSContent` from `types/opds.ts` for the helper's param.
Scope: only the detail view renders description HTML. `PublicationCard` shows no
summary; `NavigationCard` renders `SYMBOL.SUMMARY` as React-escaped plain text
(getSummary only returns `type==='text'` values) — both correct, untouched.
Verifying which render path produces "raw tags": React `{value}` text-escapes
(shows `&copy;` literally); `dangerouslySetInnerHTML` decodes one level (shows
literal `<p>` only if value is `&lt;p&gt;`). The screenshot showed literal `<p>`
AND `&quot;` → double-escaped innerHTML path, not the dead `description` path
(foliate's `getPublication` never sets `metadata.description`). Related:
[[opds-firefox-strict-xml-4479]].
@@ -1,23 +0,0 @@
---
name: opds2-json-search-4502
description: "OPDS 2.0 JSON catalog search bar greyed out; isSearchLink didn't recognize templated application/opds+json links"
metadata:
node_type: memory
type: project
originSessionId: 9eb835a8-ce7a-4f80-ae3d-94e330935585
---
#4502 — OPDS 2.0 JSON catalogs (e.g. `type: "application/opds+json"`, `templated: true`, href `/opds/search{?query}`) showed a **greyed-out** navbar search input that rejected queries.
**Root cause:** `isSearchLink` (`src/app/opds/utils/opdsUtils.ts`) only matched `MIME.OPENSEARCH`/`MIME.ATOM`, so `hasSearch` (page.tsx) was false → `<input disabled={!hasSearch}>`. `handleSearch` also only handled those two types.
**Fix:**
- Add `MIME.OPDS2 = 'application/opds+json'` + `templated?: boolean` on `OPDSBaseLink`; `isSearchLink` now also accepts `type === OPDS2 && !!templated`.
- New `expandOPDSSearchTemplate(templateHref, queryTerm)` in opdsUtils expands the RFC 6570 template, placing the term in the primary text var (`query`/`searchTerms`/`q`, else first var). Reuses `foliate-js/uri-template.js` (`replace`, `getVariables`) — do NOT reinvent RFC 6570.
- page.tsx `handleSearch` adds an `OPDS2` branch.
**Gotcha (key):** `resolveURL` mangles `{?query}` template braces (`/opds/search%7B?query}`) — it treats `?` as the query start. ALWAYS `expandOPDSSearchTemplate` FIRST, THEN `resolveURL`. For OPENSEARCH/ATOM the order is reversed (resolve then `.replace('{searchTerms}', ...)`), which is why the new branch can't share the top-level `searchURL`.
**Foliate has `getSearch(link)`** (async, OPDS 2.0 JSON → OPDSSearch via uri-template) but readest's page.tsx never wired it; the JSON path just `JSON.parse`s the feed, preserving raw `templated`/`type`. OPDS 2.0 is JSON-only (XML `getFeed` links never carry `templated`).
Related: [[opds-firefox-strict-xml-4479]].
@@ -1,16 +0,0 @@
---
name: overlayer-splitrange-textnodes
description: "Overlayer highlight rects — split range by TEXT NODES, not a block-tag selector; li/blockquote text was silently dropped from SVG when highlight also touched a <p>"
metadata:
node_type: memory
type: project
originSessionId: 58d4b1a1-8823-4474-9966-c96727692e3f
---
Bug class (3rd occurrence): `Overlayer` in `packages/foliate-js/overlayer.js` splits a range into sub-ranges before `getClientRects()` so fully-contained block elements don't contribute border boxes (over-highlighting blank space — fork commit f087826 #10). The split was `querySelectorAll('p, h1, h2, h3, h4')`; 920676b added headings after the same hole; June 2026 the hole reappeared for `<li>`: a highlight spanning paragraphs + a bullet list drew NO rects over the list (li text fell into no sub-range), while a highlight entirely inside the list worked via the `splitRanges.length === 0 → [range]` fallback.
**Fix:** `#splitRange` walks TEXT NODES (TreeWalker, `FILTER_REJECT` on non-intersecting elements prunes subtrees) plus replaced elements (`img, svg`), clipping first/last text nodes to the range boundaries. Text-node line rects never include block border boxes, cover every block type, and can't double-paint (`li > p` nesting). Shared `#getRects(range)` used by both `add()` and `redraw()`.
**Why:** any hard-coded block-tag selector is whack-a-mole (li, blockquote, dd, td, div-paragraphs…) and adding container tags double-paints nested matched tags.
**How to apply:** when highlight/overlay rects miss some content or over-paint blank space, check `#splitRange` in overlayer.js first. Test at `src/__tests__/document/overlayer-highlight-blocks.test.ts` — jsdom has no `Range.getClientRects`; stub the prototype to record `range.toString()` per call and assert covered text. Dev-web picked up the symlinked foliate-js edit on page reload (no server restart needed for next dev, unlike [[paginator-gutter-bleed-asymmetry-4394]]'s note).
@@ -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]].

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