forked from akai/readest
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2406605871 |
@@ -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"
|
||||
}
|
||||
@@ -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*
|
||||
@@ -1,3 +0,0 @@
|
||||
[*.{ts,tsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -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']
|
||||
@@ -1,23 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: describing your feature request'
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
|
||||
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
> A clear and concise description of what you'd like to happen.
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
> Add any additional context or screenshots about the feature request here.
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
|
||||
@@ -11,16 +11,22 @@ 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
|
||||
|
||||
@@ -28,8 +34,11 @@ Operating System:
|
||||
|
||||
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
|
||||
**Example:**
|
||||
|
||||
```
|
||||
Operating System: macOS 14.3.1
|
||||
Readest Version: 0.9.0
|
||||
We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
Probably fixable with CSS
|
||||
```
|
||||
|
||||
@@ -8,6 +8,6 @@ updates:
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- '*' # Group all Actions updates into a single larger pull request
|
||||
- "*" # Group all Actions updates into a single larger pull request
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL Advanced'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['main']
|
||||
pull_request:
|
||||
branches: ['main']
|
||||
schedule:
|
||||
- cron: '38 20 * * 4'
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
# Runner size impacts CodeQL analysis time. To learn more, please see:
|
||||
# - https://gh.io/recommended-hardware-resources-for-running-codeql
|
||||
# - https://gh.io/supported-runners-and-hardware-resources
|
||||
# - https://gh.io/using-larger-runners (GitHub.com only)
|
||||
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
# required for all workflows
|
||||
security-events: write
|
||||
|
||||
# required to fetch internal or private CodeQL packs
|
||||
packages: read
|
||||
|
||||
# only required for workflows in private repositories
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: rust
|
||||
build-mode: none
|
||||
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
|
||||
# Use `c-cpp` to analyze code written in C, C++ or both
|
||||
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
|
||||
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
|
||||
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
|
||||
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
|
||||
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
# or others). This is typically only required for manual builds.
|
||||
# - name: Setup runtime (example)
|
||||
# uses: actions/setup-example@v1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# If the analyze step fails for one of the languages you are analyzing with
|
||||
# "We were unable to automatically build your code", modify the matrix above
|
||||
# to set the build mode to "manual" for that language. Then modify this step
|
||||
# to build your code.
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
shell: bash
|
||||
run: |
|
||||
echo 'If you are using a "manual" build mode for one or more of the' \
|
||||
'languages you are analyzing, replace this with the commands to build' \
|
||||
'your code, for example:'
|
||||
echo ' make bootstrap'
|
||||
echo ' make release'
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
@@ -1,191 +0,0 @@
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
concurrency:
|
||||
group: publish-docker-image-${{ github.event.release.tag_name || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
env:
|
||||
BUILD_ARGS: |
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
steps:
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta (for labels)
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/readest
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: production-stage
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: ${{ env.BUILD_ARGS }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/readest:buildcache-${{ env.PLATFORM_PAIR }},mode=max
|
||||
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/readest,push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: build
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Detect Docker Hub credentials
|
||||
id: dockerhub
|
||||
run: |
|
||||
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract image metadata (GHCR only)
|
||||
id: meta-ghcr
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository_owner }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Extract image metadata (GHCR + Docker Hub)
|
||||
id: meta-all
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository_owner }}/readest
|
||||
docker.io/${{ secrets.DOCKERHUB_USERNAME }}/readest
|
||||
tags: |
|
||||
type=raw,value=main,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }}
|
||||
type=sha,prefix=sha-
|
||||
|
||||
- name: Create manifest list and push (GHCR only)
|
||||
if: steps.dockerhub.outputs.enabled != 'true'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-ghcr.outputs.json }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
|
||||
|
||||
- name: Create manifest list and push (GHCR + Docker Hub)
|
||||
if: steps.dockerhub.outputs.enabled == 'true'
|
||||
working-directory: /tmp/digests
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta-all.outputs.json }}
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf 'ghcr.io/${{ github.repository_owner }}/readest@sha256:%s ' *)
|
||||
|
||||
- name: Published image summary
|
||||
run: |
|
||||
echo "## Published Images" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Tags:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ "${{ steps.dockerhub.outputs.enabled }}" == "true" ]; then
|
||||
echo "${{ steps.meta-all.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "${{ steps.meta-ghcr.outputs.tags }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo '```' >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,246 +1,35 @@
|
||||
name: PR checks
|
||||
name: Build Web Application on Pull Request
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
rust_lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-rust-lint-${{ runner.os }}
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
|
||||
- name: Format check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo fmt --check
|
||||
- name: Clippy Check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo clippy -p Readest --no-deps -- -D warnings
|
||||
|
||||
build_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.15.1
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# Turbopack persists its build cache here (turbopackFileSystemCacheForBuild).
|
||||
# The key is deterministic (no commit SHA) so every PR restores the same
|
||||
# entry — in practice the one `main` last saved, which is the only cache
|
||||
# sibling PRs can all see.
|
||||
- name: cache Turbopack build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: apps/readest-app/.next/cache
|
||||
key: turbo-build-web-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
turbo-build-web-${{ runner.os }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: install LuaJIT (for koplugin lint)
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: run format check
|
||||
run: |
|
||||
pnpm format:check || (pnpm format && git diff && exit 1)
|
||||
|
||||
- name: run lint
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
- name: build the web app
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build-web && pnpm check:all
|
||||
|
||||
- name: cache playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web e2e tests
|
||||
id: web_e2e
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:e2e:web
|
||||
|
||||
- name: upload e2e report
|
||||
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/readest-app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
test_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
pnpm install && pnpm setup-pdfjs
|
||||
|
||||
- name: cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-test-web-${{ runner.os }}
|
||||
|
||||
- name: install LuaJIT + busted (for koplugin tests)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# luajit — pnpm test:lua
|
||||
# luarocks/libsqlite3-dev — required to build lsqlite3complete
|
||||
sudo apt-get install -y luajit luarocks libsqlite3-dev
|
||||
# Install busted + the SQLite binding the LibraryStore specs use,
|
||||
# both pinned to Lua 5.1 (LuaJIT-compatible). System-wide install
|
||||
# so `luarocks --lua-version=5.1 path` (sourced by
|
||||
# scripts/test-koplugin.mjs) picks them up.
|
||||
sudo luarocks --lua-version=5.1 install busted
|
||||
sudo luarocks --lua-version=5.1 install lsqlite3complete
|
||||
|
||||
- name: cache playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
- name: build the web App
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web tests
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:pr:web
|
||||
|
||||
- name: run koplugin tests
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:lua
|
||||
|
||||
build_tauri_app:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# The tauri tests run `next dev`, whose Turbopack cache lives in
|
||||
# `.next/dev/cache` (a different path from the `next build` cache).
|
||||
- name: cache Turbopack dev cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: apps/readest-app/.next/dev/cache
|
||||
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
turbo-dev-tauri-${{ runner.os }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
key: tauri-cargo
|
||||
cache-all-crates: 'true'
|
||||
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-tauri-${{ runner.os }}
|
||||
- name: install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb
|
||||
|
||||
- name: run tauri tests
|
||||
working-directory: apps/readest-app
|
||||
run: xvfb-run pnpm test:pr:tauri
|
||||
pnpm build-web
|
||||
|
||||
+55
-272
@@ -5,12 +5,10 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
get-release:
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.get-release.outputs.release_id }}
|
||||
@@ -19,14 +17,14 @@ jobs:
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
|
||||
- name: get release
|
||||
id: get-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
@@ -37,7 +35,7 @@ jobs:
|
||||
core.setOutput('release_tag', data.tag_name);
|
||||
- name: get release notes
|
||||
id: get-release-notes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -50,74 +48,6 @@ jobs:
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-koreader-plugin:
|
||||
needs: get-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: create KOReader plugin zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
plugin_zip="Readest-${version}-1.koplugin.zip"
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
# Exclude dev-only artifacts from the published plugin zip:
|
||||
# scripts/ — i18n + build helpers
|
||||
# docs/ — design notes
|
||||
# spec/ — busted test suite
|
||||
# .busted — busted runner config
|
||||
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
|
||||
# for local builds.
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin \
|
||||
-x 'readest.koplugin/scripts/*' \
|
||||
'readest.koplugin/docs/*' \
|
||||
'readest.koplugin/spec/*' \
|
||||
'readest.koplugin/.busted'
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
permissions:
|
||||
@@ -126,182 +56,81 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- os: ubuntu-latest
|
||||
release: android
|
||||
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||
- os: ubuntu-22.04
|
||||
release: linux
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
release: linux
|
||||
- os: ubuntu-22.04
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
release: macos
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
args: '--target universal-apple-darwin'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
args: '--target x86_64-pc-windows-msvc --bundles nsis'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: aarch64
|
||||
rust_target: aarch64-pc-windows-msvc
|
||||
args: '--target aarch64-pc-windows-msvc --bundles nsis'
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.14.4
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
run: |
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: build and upload Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
- name: copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
pushd src-tauri/gen/android
|
||||
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
|
||||
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
|
||||
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
|
||||
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
|
||||
|
||||
popd
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universial_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universial_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
|
||||
echo "Uploading $universial_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
echo "Uploading signatures to GitHub release"
|
||||
pnpm tauri signer sign $universial_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Android release
|
||||
if: matrix.config.release == 'android'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
|
||||
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
|
||||
|
||||
universial_sig=$(cat Readest_${version}_universal.apk.sig)
|
||||
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
|
||||
|
||||
jq --arg url "$universial_apk_url" \
|
||||
--arg sig "$universial_sig" \
|
||||
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
jq --arg url "$arm64_apk_url" \
|
||||
--arg sig "$arm64_sig" \
|
||||
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
echo "Uploading updated latest.json to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
|
||||
|
||||
- name: Override tauri-cli with custom AppImage format (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
||||
|
||||
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
if: matrix.config.release != 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -310,40 +139,18 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
with:
|
||||
projectPath: apps/readest-app
|
||||
# On Linux, build with the Rust `cargo tauri` CLI installed in the
|
||||
# step above so the new (truly-portable AppImage) bundler is used.
|
||||
# Without this, tauri-action falls back to the npm @tauri-apps/cli.
|
||||
tauriScript: ${{ matrix.config.release == 'linux' && 'cargo tauri' || '' }}
|
||||
releaseId: ${{ needs.get-release.outputs.release_id }}
|
||||
releaseBody: ${{ needs.get-release.outputs.release_note }}
|
||||
args: ${{ matrix.config.args || '' }}
|
||||
|
||||
- name: upload release notes to GitHub release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Uploading release notes to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
|
||||
|
||||
- name: build and upload portable binaries (Windows only)
|
||||
- name: upload portable binaries (Windows only)
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Building Portable Binaries"
|
||||
pushd apps/readest-app/
|
||||
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
|
||||
pnpm tauri build ${{ matrix.config.args }}
|
||||
|
||||
popd
|
||||
echo "Uploading Portable Binaries"
|
||||
arch=${{ matrix.config.arch }}
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
@@ -357,7 +164,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
|
||||
exe_file="apps/readest-app/src-tauri/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"
|
||||
@@ -366,51 +173,27 @@ jobs:
|
||||
echo "Uploading $bin_file to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
|
||||
|
||||
echo "Signing portable binary"
|
||||
pushd apps/readest-app/
|
||||
pnpm tauri signer sign "../../$bin_file"
|
||||
popd
|
||||
echo "Uploading signature to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Windows portable release
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
arch=${{ matrix.config.arch }}
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
platform_key="windows-x86_64-portable"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
platform_key="windows-aarch64-portable"
|
||||
else
|
||||
echo "Unknown architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
|
||||
portable_sig=$(cat $bin_file.sig)
|
||||
|
||||
jq --arg url "$portable_url" \
|
||||
--arg sig "$portable_sig" \
|
||||
--arg key "$platform_key" \
|
||||
'.platforms[$key] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
echo "Uploading updated latest.json to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, build-tauri]
|
||||
update-release:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const body = `## Release Highlight\n${process.env.release_note}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||
# by a third-party and are governed by separate terms of service, privacy
|
||||
# policy, and support documentation.
|
||||
|
||||
name: Scorecard supply-chain security
|
||||
on:
|
||||
# For Branch-Protection check. Only the default branch is supported. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||
branch_protection_rule:
|
||||
# To guarantee Maintained check is occasionally updated. See
|
||||
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||
schedule:
|
||||
- cron: '26 4 * * 3'
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
name: Scorecard analysis
|
||||
runs-on: ubuntu-latest
|
||||
# `publish_results: true` only works when run from the default branch. conditional can be removed if disabled.
|
||||
if: github.event.repository.default_branch == github.ref_name || github.event_name == 'pull_request'
|
||||
permissions:
|
||||
# Needed to upload the results to code-scanning dashboard.
|
||||
security-events: write
|
||||
# Needed to publish results and get a badge (see publish_results below).
|
||||
id-token: write
|
||||
# Uncomment the permissions below if installing in a private repository.
|
||||
# contents: read
|
||||
# actions: read
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: "Run analysis"
|
||||
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||
# - you are installing Scorecard on a *private* repository
|
||||
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
|
||||
# Public repositories:
|
||||
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||
# - Allows the repository to include the Scorecard badge.
|
||||
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||
# For private repositories:
|
||||
# - `publish_results` will always be set to `false`, regardless
|
||||
# of the value entered here.
|
||||
publish_results: true
|
||||
|
||||
# (Optional) Uncomment file_mode if you have a .gitattributes with files marked export-ignore
|
||||
# file_mode: git
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||
- name: "Upload to code-scanning"
|
||||
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
@@ -1,73 +0,0 @@
|
||||
name: Upload Release Assets to R2
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag name (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
upload-to-r2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
RELEASE_R2_BUCKET: readest-releases
|
||||
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
|
||||
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
|
||||
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
run: |
|
||||
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rclone
|
||||
|
||||
- name: Configure rclone
|
||||
run: |
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<EOF
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
|
||||
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
|
||||
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
- name: Modify latest.json download URLs
|
||||
run: |
|
||||
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
|
||||
READEST_BASE_URL="https://download.readest.com/releases"
|
||||
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
|
||||
|
||||
- name: Upload to R2
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./release-assets/latest.json releases
|
||||
mv ./release-assets/release-notes.json releases
|
||||
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
|
||||
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
|
||||
|
||||
- name: Upload successful
|
||||
if: success()
|
||||
run: echo "Upload completed successfully"
|
||||
@@ -4,20 +4,20 @@ on:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
|
||||
- uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
|
||||
-19
@@ -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
@@ -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 +0,0 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm -C apps/readest-app format:check
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
@@ -0,0 +1 @@
|
||||
packages/foliate-js/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"biomejs.biome",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-26
@@ -4,29 +4,4 @@
|
||||
"packages/tauri/Cargo.toml",
|
||||
"apps/readest-app/src-tauri/Cargo.toml"
|
||||
],
|
||||
// "editor.formatOnSave": true, // uncomment to add format on save
|
||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
||||
"typescript.inlayHints.variableTypes.enabled": true,
|
||||
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"typescript.inlayHints.enumMemberValues.enabled": true,
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,14 +3,14 @@
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
git clone https://github.com/chrox/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
@@ -43,8 +43,8 @@ git submodule update --init --recursive
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
@@ -75,7 +75,7 @@ Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
@@ -86,7 +86,7 @@ Recommended Visual Studio Code plugins for development:
|
||||
|
||||
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
|
||||
- VS Code ESLint extension (dbaeumer.vscode-eslint)
|
||||
- Biome - Code formatter and linter (biomejs.biome)
|
||||
- Prettier - Code formatter (esbenp.prettier-vscode)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Generated
-10581
File diff suppressed because it is too large
Load Diff
-42
@@ -1,42 +0,0 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"apps/readest-app/src-tauri",
|
||||
"packages/tauri/crates/tauri",
|
||||
"packages/tauri-plugins/plugins/fs"
|
||||
]
|
||||
exclude = [
|
||||
"packages/qcms"
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
log = "0.4"
|
||||
tauri = { version = "2", default-features = false }
|
||||
tauri-build = "2"
|
||||
tauri-plugin = "2"
|
||||
tauri-utils = "2"
|
||||
schemars = "0.8"
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
glob = "0.3"
|
||||
zbus = "5.9"
|
||||
dunce = "1"
|
||||
url = "2"
|
||||
tar = "0.4.45"
|
||||
nix = "0.20.2"
|
||||
glib = "0.20.0"
|
||||
|
||||
[workspace.package]
|
||||
authors = ["Bilingify LLC"]
|
||||
homepage = "https://readest.com"
|
||||
license = "AGPL-3.0"
|
||||
repository = "https://github.com/readest/readest"
|
||||
categories = []
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
[patch.crates-io]
|
||||
tauri = { path = "packages/tauri/crates/tauri" }
|
||||
tauri-plugin-fs = { path = "packages/tauri-plugins/plugins/fs" }
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS dependencies
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/readest-app/package.json ./apps/readest-app/
|
||||
COPY patches/ ./patches/
|
||||
COPY packages/ ./packages/
|
||||
RUN --mount=type=cache,id=pnpm,sharing=locked,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN test -f packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css \
|
||||
&& test -d packages/simplecc-wasm/dist/web \
|
||||
|| { printf '\nERROR: Required git submodules are not initialized in the source directory.\nEnsure submodules are initialized before running docker build.\nRun: git submodule update --init packages/foliate-js packages/simplecc-wasm\n\n'; exit 1; }
|
||||
RUN pnpm --filter @readest/readest-app setup-vendors
|
||||
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS development-stage
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=dependencies /app /app
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
EXPOSE 3000
|
||||
ENTRYPOINT ["pnpm", "dev-web", "-H", "0.0.0.0"]
|
||||
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS build
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ARG NEXT_PUBLIC_APP_PLATFORM
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
ARG NEXT_PUBLIC_OBJECT_STORAGE_TYPE
|
||||
ARG NEXT_PUBLIC_STORAGE_FIXED_QUOTA
|
||||
ARG NEXT_PUBLIC_TRANSLATION_FIXED_QUOTA
|
||||
COPY --from=dependencies /app/node_modules /app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/node_modules /app/apps/readest-app/node_modules
|
||||
COPY --from=dependencies /app/apps/readest-app/public/vendor /app/apps/readest-app/public/vendor
|
||||
COPY --from=dependencies /app/packages/foliate-js/node_modules /app/packages/foliate-js/node_modules
|
||||
COPY . .
|
||||
WORKDIR /app/apps/readest-app
|
||||
RUN pnpm build-web
|
||||
|
||||
FROM docker.io/library/node:24-slim@sha256:24dc26ef1e3c3690f27ebc4136c9c186c3133b25563ae4d7f0692e4d1fe5db0e AS production-stage
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
RUN corepack prepare pnpm@11.1.1 --activate
|
||||
WORKDIR /app
|
||||
COPY --from=build /app /app
|
||||
WORKDIR /app/apps/readest-app
|
||||
ENTRYPOINT ["pnpm", "start-web", "-H", "0.0.0.0"]
|
||||
EXPOSE 3000
|
||||
@@ -5,22 +5,17 @@
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![Discord][badge-discord]][link-discord]
|
||||
[![Reddit][badge-reddit]][link-reddit]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Language Coverage][badge-language-coverage]][link-locales]
|
||||
[![Donate][badge-donate]][link-donate]
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
[![][badge-hellogithub]][link-hellogithub]
|
||||
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,14 +25,13 @@
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#support">Support</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -45,38 +39,38 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
<div align="left">🛠 Building</div>
|
||||
<div align="left">🔄 Planned</div>
|
||||
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
|
||||
@@ -84,35 +78,24 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
### Mobile Apps
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
<div align="center">
|
||||
<a href="https://apps.apple.com/app/id6738622779">
|
||||
<img alt="Download on the App Store" src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" style="height: 50px;" /></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.bilingify.readest">
|
||||
<img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" style="height: 50px;" /></a>
|
||||
</div>
|
||||
|
||||
### Platform-Specific Downloads
|
||||
|
||||
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
|
||||
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Linux users can also install [Readest on Flathub][link-flathub].
|
||||
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -122,8 +105,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
```bash
|
||||
nvm install v24
|
||||
nvm use v24
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
@@ -137,16 +120,16 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
git submodule update --init --recursive
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
### 3. Verify Dependencies Installation
|
||||
@@ -164,169 +147,45 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
# preview with OpenNext build for the Web app
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri android dev --host
|
||||
```
|
||||
|
||||
For iOS:
|
||||
|
||||
```bash
|
||||
# Set up the iOS environment (run once)
|
||||
pnpm tauri ios init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri ios dev --host
|
||||
```
|
||||
|
||||
### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
with all the necessary dependencies:
|
||||
|
||||
```bash
|
||||
nix develop ./ops # enter a dev shell for the web app
|
||||
nix develop ./ops#ios # enter a dev shell for the ios app
|
||||
nix develop ./ops#android # enter a dev shell for the android app
|
||||
```
|
||||
|
||||
### 7. More information
|
||||
### 6. More information
|
||||
|
||||
Please check the [wiki][link-gh-wiki] of this project for more information on development.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Readest Won’t 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 doesn’t 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 you’ve 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 system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
|
||||
<a href="https://github.com/readest/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
<img width="200" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following libraries and frameworks are used in this software:
|
||||
The following JavaScript libraries are bundled in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
|
||||
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
@@ -336,19 +195,12 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
|
||||
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=green
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[badge-donate]: https://donate.readest.com/badge.svg
|
||||
[badge-deepwiki]: https://deepwiki.com/badge.svg
|
||||
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
|
||||
[badge-language-coverage]: https://img.shields.io/badge/coverage-53%25%20population%20🌍-green
|
||||
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
[link-gh-commits]: https://github.com/readest/readest/commits/main
|
||||
@@ -357,8 +209,3 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
|
||||
[link-discord]: https://discord.gg/gntyVNk3BJ
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
|
||||
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
|
||||
[link-reddit]: https://reddit.com/r/readest/
|
||||
|
||||
-142
@@ -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 1–2)
|
||||
|
||||
- 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 1–3)
|
||||
|
||||
- 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 3–14, 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 +0,0 @@
|
||||
../.claude/memory
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/plans
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/rules
|
||||
@@ -1,59 +0,0 @@
|
||||
# Readest Project Memory
|
||||
|
||||
## Key Reference Documents
|
||||
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
|
||||
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
|
||||
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
|
||||
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
|
||||
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
|
||||
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
|
||||
|
||||
## Paginator Scroll Knowledge
|
||||
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
|
||||
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
|
||||
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
|
||||
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
|
||||
|
||||
## Critical Files (Most Bug-Prone)
|
||||
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
|
||||
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
|
||||
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
|
||||
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
|
||||
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
|
||||
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
|
||||
|
||||
## Sync Notes
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
|
||||
## Feature Notes
|
||||
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
|
||||
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
|
||||
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
|
||||
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
|
||||
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
|
||||
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
|
||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
|
||||
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
|
||||
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
|
||||
@@ -1,90 +0,0 @@
|
||||
# Annotator & Reader Fixes Reference
|
||||
|
||||
## Annotation System Architecture
|
||||
|
||||
### Key Components
|
||||
- `Annotator.tsx` - Annotation lifecycle, popup display, style/color management
|
||||
- `AnnotationRangeEditor.tsx` - Drag handles for adjusting selection range
|
||||
- `MagnifierLoupe.tsx` - Magnifying glass during handle drag (mobile only)
|
||||
- `useTextSelector.ts` - Text selection detection and processing
|
||||
- `useAnnotationEditor.ts` - Editing existing annotations
|
||||
- `useInstantAnnotation.ts` - Creating new annotations on selection
|
||||
|
||||
### Highlight Rendering
|
||||
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
|
||||
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
|
||||
- `Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
|
||||
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
|
||||
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
|
||||
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
|
||||
|
||||
### Multiview Overlayer Pitfalls
|
||||
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
|
||||
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
|
||||
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
|
||||
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
|
||||
|
||||
## Fix History
|
||||
|
||||
| Issue | Problem | Root Cause | Fix |
|
||||
|-------|---------|------------|-----|
|
||||
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
|
||||
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
|
||||
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
|
||||
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
|
||||
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
|
||||
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
|
||||
| #3225 | Android tools unresponsive | Premature `makeSelection()` call | Remove premature re-selection in Android path |
|
||||
|
||||
## Common Annotation Bugs
|
||||
|
||||
### Selection Issues
|
||||
- **Word snapping**: Uses `Intl.Segmenter` with `granularity: 'word'` to snap selection to word boundaries
|
||||
- **Android re-selection**: Don't call `makeSelection(sel, index, true)` immediately on pointer-up; let the popup flow complete
|
||||
- **Range editor handles**: Remove `initializedRef` guards that prevent re-computation when switching annotations
|
||||
|
||||
### Color/Style Issues
|
||||
- **Custom colors in sidebar**: Use inline `style={{ backgroundColor: 'color-mix(...)' }}` not Tailwind classes
|
||||
- **Style synchronization**: Consolidate `selectedStyle` and `selectedColor` into one `useEffect`
|
||||
- **Switching annotations**: Must call `setShowAnnotPopup(false)` and `setEditingAnnotation(null)` before setting up new annotation
|
||||
|
||||
## Reader/Content Fixes
|
||||
|
||||
### Progress Display
|
||||
- Use physical `view.renderer.page` and `view.renderer.pages` for page counts (#3213, #3200)
|
||||
- Last page shows 100% by fixing boundary condition (#3383)
|
||||
- FB2 subsections need special handling for progress (#3136)
|
||||
|
||||
### Translation View (#3078)
|
||||
- Problem: Page jumps back during full-text translation
|
||||
- Root cause: DOM mutations from sequential translation insertions cause paginator relayout
|
||||
- Fix: Batch DOM updates with 50ms timer, use bounded concurrent queue (max 5), show loading overlay
|
||||
|
||||
### TOC Navigation (#3124)
|
||||
- Problem: Expanding TOC chapter scrolls back to current chapter
|
||||
- Fix: Only scroll-into-view on navigation, not on expand/collapse
|
||||
|
||||
## Accessibility (a11y) Fixes
|
||||
|
||||
### Screen Reader (TalkBack) Support
|
||||
- **Page indicator updates** (#2276): Add focus handlers on `<p>` elements that call `view.goTo(cfi)` to update position
|
||||
- **Navigation buttons** (#3036): Always show prev/next buttons when screen reader active; `PageNavigationButtons.tsx`
|
||||
- **Dropdown menus** (#3035): Use `DropdownContext` with overlay dismiss instead of blur-based closing
|
||||
|
||||
### Dropdown Architecture for a11y
|
||||
- `DropdownContext` (`src/context/DropdownContext.tsx`) manages which dropdown is open globally
|
||||
- Uses `useId()` for unique identification
|
||||
- One dropdown open at a time
|
||||
- `<Overlay>` for dismissal (tap/click outside) instead of `onBlur`
|
||||
- `<details>` element with `open={isOpen}` for semantic structure
|
||||
- No auto-focus-first-item (conflicts with TalkBack)
|
||||
|
||||
## E-ink Readability
|
||||
- Use `not-eink:` Tailwind variant for colors and opacity (#3258)
|
||||
- Don't use `text-primary` (blue) or low opacity on e-ink
|
||||
- Highlights use foreground color in dark mode e-ink (#3299)
|
||||
|
||||
## Key Utility Functions
|
||||
- `snapRangeToWords()` in `src/utils/sel.ts` - Word boundary snapping
|
||||
- `handleAccessibilityEvents()` in `src/utils/a11y.ts` - Screen reader focus handling
|
||||
- `color-mix()` CSS function for custom highlight colors with opacity
|
||||
@@ -1,133 +0,0 @@
|
||||
# Bug Fixing Patterns & Strategies
|
||||
|
||||
## Common Root Cause Categories
|
||||
|
||||
### 1. Overly Broad CSS Selectors
|
||||
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
|
||||
**Examples:**
|
||||
- `hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
|
||||
- `p img { mix-blend-mode }` applied to block images, not just inline (#3112)
|
||||
- `svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
|
||||
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
|
||||
|
||||
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
|
||||
|
||||
### 2. Conditional vs Unconditional Style Overrides
|
||||
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
|
||||
**Examples:**
|
||||
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
|
||||
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
|
||||
|
||||
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
|
||||
|
||||
### 3. Missing EPUB Stylesheet Transformations
|
||||
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
|
||||
**Examples:**
|
||||
- `user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
|
||||
- `font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
|
||||
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
|
||||
|
||||
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
|
||||
|
||||
### 4. Stale State / Refs Not Reset
|
||||
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
|
||||
**Examples:**
|
||||
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
|
||||
- `initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
|
||||
- `view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
|
||||
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
|
||||
|
||||
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
|
||||
|
||||
### 5. Platform API Differences
|
||||
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
|
||||
**Examples:**
|
||||
- `navigator.getGamepads()` returns null on older Android WebView (#3245)
|
||||
- `CompressionStream` unavailable on some Android versions (#3255)
|
||||
- `btoa()` throws on non-ASCII characters (#3436)
|
||||
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
|
||||
- `document.startViewTransition()` crashes on Linux
|
||||
|
||||
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
|
||||
|
||||
### 6. Safe Area Inset Issues
|
||||
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
|
||||
**Examples:**
|
||||
- Zoom controls behind status bar (#3426)
|
||||
- Android navigation bar overlap (#3466)
|
||||
- iPad sidebar insets incorrect (#3395)
|
||||
- Reader page layout jump after system UI change (#3469)
|
||||
|
||||
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
|
||||
|
||||
### 7. Z-Index Layering Issues
|
||||
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
|
||||
**Examples:**
|
||||
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
|
||||
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
|
||||
- Page nav buttons behind TTS control (#3184)
|
||||
|
||||
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
|
||||
|
||||
### 8. Event Handling Race Conditions
|
||||
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
|
||||
**Examples:**
|
||||
- macOS context menu steals pointer event loop (#3324) -> 100ms setTimeout delay
|
||||
- Traffic light buttons flicker due to timeout race (#3488, #3129)
|
||||
- Android tool buttons unresponsive due to premature re-selection (#3225)
|
||||
|
||||
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
|
||||
|
||||
### 9. foliate-js Rendering Issues
|
||||
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
|
||||
**Examples:**
|
||||
- Image size not constrained in double-page mode (#3432)
|
||||
- Background not shown in scrolled mode (#3344)
|
||||
- Section content cached incorrectly after mode switch (#3242, #3206)
|
||||
- Swipe sensitivity too low for non-animated paging (#3310)
|
||||
|
||||
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
|
||||
|
||||
### 10. Progress/Navigation Calculation Errors
|
||||
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
|
||||
**Examples:**
|
||||
- Progress shows 99.9% at last page (#3383) -> boundary condition
|
||||
- Pages left shows estimated instead of physical count (#3213, #3200)
|
||||
- FB2 subsection progress wrong (#3136) -> nested structure not handled
|
||||
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
|
||||
|
||||
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
|
||||
|
||||
### 11. Debounced State Stale on User-Initiated Layout Change
|
||||
**Pattern:** A scroll/resize handler is debounced for performance, but during the debounce window any code path that re-runs layout based on saved state (e.g. `#anchor`, `#primaryIndex`) sees stale values.
|
||||
**Example:**
|
||||
- Scrolled-mode toggle reverted to previous chapter (#3987): the paginator's scroll handler is debounced 250 ms, so toggling `flow=scrolled → flow=paginated` within that window made `render() → scrollToAnchor(#anchor)` restore the anchor from before the user scrolled into the next section. Both `#anchor` and `#primaryIndex` were stale together, sending the position back.
|
||||
|
||||
**Fix Strategy:** When an external trigger forces a re-render (here, `setAttribute('flow', ...)`), flush the debounced state synchronously *before* changing the layout. In paginator.js this means overriding `setAttribute` and calling `#detectPrimaryView()` + `#getVisibleRange()` while `this.scrolled` is still true.
|
||||
|
||||
### 12. Multiview Paginator Side Effects
|
||||
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
|
||||
**Examples:**
|
||||
- `load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
|
||||
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
|
||||
- `MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
|
||||
|
||||
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
|
||||
|
||||
### 13. Whole-Field-Synced Flag Reaches an Unsupported Platform
|
||||
**Pattern:** A setting is whole-field synced across devices (e.g. `dictionarySettings.providerEnabled`), so a flag enabled on one platform arrives `true` on a platform where that feature isn't supported. The lookup/runtime path correctly gates on platform support, but a *secondary consumer* (usually UI gating) reads the raw synced flag and misbehaves.
|
||||
**Example:**
|
||||
- System Dictionary enabled on macOS synced to web → web's `CustomDictionaries.tsx` locked all other dictionary toggles read-only (`lockedBySystem`) even though System Dictionary is hidden + a no-op there. The annotator's lookup path used the platform-gated `isSystemDictionaryEnabled(settings)` (registry.ts, gates on `isSystemDictionarySupported()`), but the settings UI compared the raw `providerEnabled[systemDictionary] === true`.
|
||||
|
||||
**Fix Strategy:** Every consumer of a synced flag for a platform-specific feature must route through the *same* platform-aware gate the runtime uses — not the raw `providerEnabled[...]`/setting value. Here: `lockedBySystem = isSystemDictionaryEnabled(settings) && ...`. Search for other readers of the raw flag when fixing one.
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
1. **Identify the category** from the issue description
|
||||
2. **Check `style.ts`** first for any CSS-related visual bugs
|
||||
3. **Check foliate-js** for rendering/layout bugs
|
||||
4. **Check platform-specific code** for mobile/desktop differences
|
||||
5. **Write a failing test** before implementing the fix
|
||||
6. **Test in both paginated and scrolled modes** for layout changes
|
||||
7. **Test on multiple platforms** for any UI change
|
||||
8. **Run `pnpm build-check`** before submitting
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: Cloudflare Workers WebSocket
|
||||
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
|
||||
type: project
|
||||
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
|
||||
---
|
||||
# Cloudflare Workers WebSocket on readest-app
|
||||
|
||||
## Why the Node `ws` package fails
|
||||
|
||||
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
|
||||
|
||||
```
|
||||
The options.createConnection option is not implemented
|
||||
```
|
||||
|
||||
This applies even with `compatibility_flags = ["nodejs_compat"]`.
|
||||
|
||||
## Correct pattern: fetch-based upgrade
|
||||
|
||||
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
|
||||
|
||||
```ts
|
||||
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
|
||||
const response = (await fetch(upgradeUrl, {
|
||||
headers: { ...baseHeaders, Upgrade: 'websocket' },
|
||||
})) as Response & { webSocket?: WebSocket & { accept(): void } };
|
||||
|
||||
if (response.status !== 101 || !response.webSocket) {
|
||||
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const ws = response.webSocket;
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.accept();
|
||||
ws.send(payload);
|
||||
```
|
||||
|
||||
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'` — `WebSocketPair` is a Workers-only global.
|
||||
|
||||
## Binary frames arrive as Blob (critical)
|
||||
|
||||
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
|
||||
|
||||
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
|
||||
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```ts
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
|
||||
pending = pending.then(async () => {
|
||||
const buf = await getBuf();
|
||||
appendBinary(buf);
|
||||
});
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
|
||||
else if (data instanceof ArrayBuffer) enqueue(() => data);
|
||||
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
|
||||
data.byteOffset, data.byteOffset + data.byteLength,
|
||||
));
|
||||
// ... handle text path: turn.end
|
||||
// -> await pending, then resolve
|
||||
});
|
||||
```
|
||||
|
||||
## Where this is used
|
||||
|
||||
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
|
||||
@@ -1,74 +0,0 @@
|
||||
# CSS & Style Fixes Reference
|
||||
|
||||
## The `style.ts` Pipeline (`src/utils/style.ts`)
|
||||
|
||||
This is the most bug-prone file in the codebase (14+ fixes). It handles all EPUB CSS transformations.
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### `getLayoutStyles()`
|
||||
- Always-active styles applied to every EPUB section
|
||||
- Controls: line-height, hyphens, image sizing, table display
|
||||
- Rules here should NOT be conditional on user settings
|
||||
- Common mistake: putting color-related rules here instead of `getColorStyles()`
|
||||
|
||||
#### `getColorStyles()`
|
||||
- Conditionally applied when user enables "Override Book Color"
|
||||
- Controls: foreground/background colors, mix-blend modes, image backgrounds
|
||||
- Gate rules on `overrideColor` flag
|
||||
|
||||
#### `transformStylesheet()`
|
||||
- Regex-based rewriting of EPUB CSS at load time
|
||||
- Runs on every stylesheet loaded from the EPUB
|
||||
- Used to neutralize problematic EPUB CSS declarations
|
||||
|
||||
#### `applyTableStyle()`
|
||||
- Post-render function that scales tables to fit available width
|
||||
- Uses `getComputedStyle()` (not inline `style.width`) to read actual width
|
||||
- Has two scaling paths: column-width-based and parent-container-based
|
||||
|
||||
### Fix History by Issue
|
||||
|
||||
| Issue | Problem | Fix in style.ts |
|
||||
|-------|---------|-----------------|
|
||||
| #3494 | Line spacing not on `<li>` | Added `li` CSS rule for `line-height` and `hyphens` |
|
||||
| #3448 | Calibre colors persist | Moved `.calibre` unset to `getColorStyles()`, added `background-color: unset` |
|
||||
| #3441 | Body padding/margin | Added `padding: unset; margin: unset` to body in `getLayoutStyles()` |
|
||||
| #3316 | Image bg unconditional | Made `background-color` rule conditional on `overrideColor` |
|
||||
| #3377 | Image bg override | Same pattern as #3316, only override when `overrideColor` is true |
|
||||
| #3334 | Generic font-family | `transformStylesheet()` replaces `font-family: serif/sans-serif` with `unset` on body |
|
||||
| #3370 | user-select: none | `transformStylesheet()` replaces all `user-select: none` with `unset` |
|
||||
| #3284 | Table scaling | Added fallback when `totalTableWidth` is 0 but parent has width |
|
||||
| #3351 | Table display broken | Added `display: table !important` to table rule |
|
||||
| #3274 | Image dimensions | Changed selectors to `:where(:not([width]))` and `:where(:not([height]))` |
|
||||
| #3205 | Table width reading | Changed from `style.width` to `getComputedStyle().width`, fixed CSS var unit |
|
||||
| #3112 | Mix-blend on all images | Narrowed selector to `.has-text-siblings` class |
|
||||
| #3086 | Mix-blend on hr | Narrowed selector to `hr.background-img` |
|
||||
| #3012 | Vertical alignment | Fixed available dimensions (subtract insets), replaced `100vw/vh` with CSS vars |
|
||||
|
||||
### Common Patterns
|
||||
|
||||
1. **Adding new element rules:** Copy the pattern from `p` rules (e.g., adding `li` for #3494)
|
||||
2. **EPUB CSS neutralization:** Add regex in `transformStylesheet()` to replace problematic declarations
|
||||
3. **Conditional overrides:** Use `overrideColor`/`overrideLayout` flags to gate rules
|
||||
4. **Selector narrowing:** Use class qualifiers or attribute pseudo-selectors to avoid over-matching
|
||||
5. **Table fixes:** Always use `getComputedStyle()`, not inline style. Check both width paths.
|
||||
|
||||
### CSS Variables from foliate-js
|
||||
|
||||
- `--available-width` - Usable content width (set by paginator.js)
|
||||
- `--available-height` - Usable content height
|
||||
- `--full-width` - Full viewport width (numeric, multiply by 1px)
|
||||
- `--full-height` - Full viewport height (numeric, multiply by 1px)
|
||||
- `--overlayer-highlight-opacity` - Highlight transparency (default 0.3)
|
||||
|
||||
### foliate-js Rendering (`packages/foliate-js/paginator.js`)
|
||||
|
||||
Key functions:
|
||||
- `columnize()` - Paginated layout path
|
||||
- `scrolled()` - Scrolled layout path
|
||||
- `setImageSize()` - Constrains image dimensions to available space
|
||||
- `#replaceBackground()` - Transfers EPUB backgrounds to paginator layer
|
||||
- `snap()` - Swipe gesture detection for page turning
|
||||
|
||||
Common issue: A fix applied to `columnize()` but not `scrolled()` (or vice versa). Always check both paths.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: D-pad Navigation Design
|
||||
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
|
||||
type: project
|
||||
---
|
||||
|
||||
## D-pad Navigation Architecture
|
||||
|
||||
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
|
||||
- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
|
||||
- `src/helpers/shortcuts.ts` — `onToggleToolbar` (Enter key) toggles reader toolbar visibility.
|
||||
- `src/app/reader/hooks/useBookShortcuts.ts` — `toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
|
||||
- `src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
|
||||
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
|
||||
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
|
||||
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
|
||||
|
||||
### Pitfalls
|
||||
|
||||
1. **WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
|
||||
|
||||
2. **`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
|
||||
|
||||
3. **Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
|
||||
|
||||
4. **Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
|
||||
|
||||
5. **Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
|
||||
|
||||
6. **Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
|
||||
|
||||
7. **`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: empty-start-cfi-sync
|
||||
description: "Invalid synced-progress CFIs like epubcfi(/6/24!/4,,/20/1:58) — the empty-start range bug from the cfi-inert skip-link, and the read-side normalizeLocationCfi sanitizer"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: ffa4a291-55fa-4cd5-8e35-0ac2852ff5c9
|
||||
---
|
||||
|
||||
Synced progress CFIs of the form `epubcfi(/6/24!/4,,/20/1:58)` (a range with an
|
||||
**empty start** component — the `,,`) are invalid: the start collapses to the
|
||||
section beginning `(body, 0)` while the end reaches the section's last block, so
|
||||
a receiving device navigates to the **wrong end** of the section.
|
||||
|
||||
**Root cause** — the cfi-inert a11y skip-link (`a11y.ts` prepends a 1×1
|
||||
`position:absolute` `<div cfi-inert>` as body's first child). There was a
|
||||
~2.5-month transitional window (foliate `c558766` 2026-03-11 → `569cc06`
|
||||
2026-05-30) where `epubcfi.js getChildNodes` already skipped `cfi-inert` but
|
||||
`paginator.js getVisibleRange` did **not** yet reject it. The relocate range's
|
||||
START could anchor on the skip-link; `fromRange`→`nodeToParts` asks for its
|
||||
index, `getChildNodes` filters it out, `findIndex` returns -1, the
|
||||
`.filter(x => x.index !== -1)` drops the step, and the start collapses to the
|
||||
body boundary → empty start. (Symmetric empty-END form from the next-section
|
||||
skip-link on a section's last page.)
|
||||
|
||||
**Generation is fixed** by `569cc06` (live on `dev` via `c23c21d37`) — but that
|
||||
does NOT repair CFIs already stored on the sync server. Those keep being served.
|
||||
|
||||
**Fix (this work):** `isMalformedLocationCfi(cfi)` predicate in `src/utils/cfi.ts`
|
||||
— true for a degenerate range (empty `parts.start` or `parts.end` via
|
||||
`CFI.parse`). Chose **discard over repair** (user call): don't derive a position
|
||||
from a corrupt CFI; drop it and let a known-good fallback win.
|
||||
- Applied ONLY at `useProgressSync.ts` `applyRemoteProgress`: a malformed
|
||||
`syncedConfig.location` is set to `undefined` so it can't drive goTo, can't win
|
||||
the `CFI.compare` gate, and is filtered out of the persisted config (local
|
||||
location kept; stops re-propagation). A valid `xpointer` still recovers the
|
||||
real position via `getCFIFromXPointer`.
|
||||
- Applied at `useKOSync.ts` `generateKOProgress` (push side): if local
|
||||
`progress.location` is malformed, skip the CFI→XPointer conversion and reuse
|
||||
the last known-good `config.xpointer`. Critical because once a bad CFI is
|
||||
pushed as an XPointer the "malformed" signal is lost — other devices pull a
|
||||
plain XPointer pointing at the wrong section end and can't discard it. The
|
||||
kosync RECEIVE path needs no guard: `getCFIFromXPointer` builds point CFIs from
|
||||
point XPointers, which can't take the empty-start form.
|
||||
- Deliberately NOT applied to `FoliateViewer.tsx` open path — that uses the
|
||||
user's OWN local `config.location`; discarding it would dump them at book start
|
||||
(`goToFraction(0)`). Left untouched per user preference; a legacy local bad
|
||||
value self-heals on the next page-turn save.
|
||||
|
||||
Tests: predicate in `__tests__/utils/cfi.test.ts`; repro + flag in
|
||||
`__tests__/utils/epubcfi-inert.test.ts`; discard behavior (no goTo, not
|
||||
persisted) in `__tests__/hooks/useProgressSync.test.tsx`.
|
||||
Related: [[kosync-cfi-spine-resolution]].
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Design rules live in DESIGN.md
|
||||
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
|
||||
type: feedback
|
||||
originSessionId: 85757e57-a029-40f8-b098-88039c43514b
|
||||
---
|
||||
The project's design system is documented at `apps/readest-app/DESIGN.md`.
|
||||
**When the user articulates a UI/UX rule** ("X should always Y", "we follow Z
|
||||
convention"), add it to DESIGN.md so it persists for the team and for future
|
||||
sessions — don't just apply it inline and move on.
|
||||
|
||||
**Why:** Readest's UI is Adwaita-aligned, e-ink-first, cross-platform-aware.
|
||||
A doc'd system avoids drift across panels and gives reviewers a reference
|
||||
point. The user explicitly asked to "remember the UI/UX rules somewhere"
|
||||
when refining the OPDS Integration sub-page.
|
||||
|
||||
**How to apply:** When the user surfaces a new rule:
|
||||
1. Add it to the appropriate `DESIGN.md` section (numbered principles in §2,
|
||||
anatomy details in §5, anti-patterns in §10).
|
||||
2. Cross-reference from related sections so it's discoverable from multiple
|
||||
entry points.
|
||||
3. Save the actual code change reference if it captures a canonical example.
|
||||
|
||||
**Rules already codified there (don't re-invent — reference instead):**
|
||||
- §2.1–2.7: surface continuity, color discipline, two-step depth, localized
|
||||
hover, motion=color, eink-first, focus visibility.
|
||||
- §2.8: RTL — always use logical properties (`ps`/`pe`/`ms`/`me`/`text-start`
|
||||
/`text-end`/`border-s`/`border-e`/`start-*`/`end-*`). Never `pl`/`pr`/`ml`
|
||||
/`mr`/`text-left`/`text-right`/`left-*`/`right-*`. The user is strict on
|
||||
this — Readest ships RTL languages.
|
||||
- §2.9: every panel/sub-page must open with title + one-line description.
|
||||
- §3: surface tier hierarchy (window/view/card → base-200/100-tinted/100).
|
||||
- §4: action vocabulary (Accent CTA, Suggested, Flat, Pill, Destructive,
|
||||
ListExtension).
|
||||
- §5: boxed list anatomy + uniform row height (`min-h-14 items-center`,
|
||||
never `py-3`) + chromeless controls inside the box + end-aligned values
|
||||
with custom `<MdArrowDropDown>` icon (don't trust daisyui's bg-image
|
||||
chevron for trailing-edge alignment).
|
||||
- §8: e-ink overlay rules.
|
||||
- §10: anti-pattern catalog with real before/after examples.
|
||||
|
||||
**Common file paths to remember:**
|
||||
- `apps/readest-app/DESIGN.md` — source of truth.
|
||||
- `apps/readest-app/src/components/settings/SubPageHeader.tsx` — title +
|
||||
description sub-page primitive that embodies §2.9.
|
||||
- `apps/readest-app/src/components/settings/integrations/` — the canonical
|
||||
reference implementation of the boxed-list-with-rows pattern.
|
||||
- `apps/readest-app/src/styles/globals.css` — eink overlay rules at
|
||||
`[data-eink='true']`.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Don't push on every change
|
||||
description: Commit when work is done; don't auto-push every iteration during active debugging
|
||||
type: feedback
|
||||
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
|
||||
---
|
||||
Don't `git push` after each commit while a bug is being actively iterated on. Commit locally as needed but hold the push.
|
||||
|
||||
**Why:** When a fix doesn't actually solve the user-reported bug, every push is wasted CI cycles + remote churn the user has to look past on the PR. The user is testing live and will tell us when something's actually verified.
|
||||
|
||||
**How to apply:** During debugging or fix iterations on a single user-reported bug, commit locally only. Push when (a) the user confirms the fix works, (b) the user explicitly asks to push, or (c) we hit a clean done-state on a multi-step task. New commits + lint/test green is not enough.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: en/translation.json holds ONLY plural variants and proper-noun overrides
|
||||
description: For non-plural strings, do NOT add to en/translation.json — the source-code key IS the en value via `defaultValue: key`. ONLY plural strings need explicit `_one`/`_other` entries in en, because i18next needs the forms to pick from.
|
||||
type: feedback
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
|
||||
**Rule:** `public/locales/en/translation.json` is hand-curated and contains essentially two kinds of entries:
|
||||
|
||||
1. **Plural variants** (`<base>_one`, `<base>_other`, and locale-specific `_few`/`_many`/etc. as needed by CLDR). i18next MUST find these to know which form to render — without them, `count: 1` falls back to the bare key like `{{count}} days` and renders "1 days" instead of "1 day".
|
||||
2. **Proper-noun overrides** (e.g., font names like `LXGW WenKai GB Screen`) where the en value differs from the key.
|
||||
|
||||
Everything else — ordinary translatable strings like `Sign in to share books` — does NOT belong in en/translation.json. The translation hook calls `t(key, { defaultValue: key, ...options })`, so for any string not in en/translation.json, i18next renders the key itself. That's why a 51-key en file works for a codebase with thousands of `_()` callsites.
|
||||
|
||||
**Why en is hand-curated:** the project's `i18next-scanner.config.cjs` lists every locale EXCEPT `en` in its `lngs` array. The scanner generates `__STRING_NOT_TRANSLATED__` placeholders only for the listed locales; `en` is never touched.
|
||||
|
||||
**Workflow when adding `_(...)` calls:**
|
||||
|
||||
- **Non-plural string** (e.g. `_('Sign in to share books')`): add the `_(...)` call, run `pnpm run i18n:extract`, translate the new placeholders in non-en locales. **Do NOT touch `en/translation.json`** — the key itself is the en value.
|
||||
- **Plural string** (e.g. `_('{{count}} days', { count: n })`): same as above, PLUS hand-add `<base>_one` and `<base>_other` to `en/translation.json` (and `_few`/`_many`/etc. only if the source language ever needs them, which English doesn't). Convention from existing entries (e.g., `Are you sure to delete {{count}} selected book(s)?_one` → `Are you sure to delete {{count}} selected book?`): keep `{{count}}` interpolated even in `_one`, and swap any `(s)` placeholder to the proper singular/plural noun.
|
||||
|
||||
**Audit script:** walk `src/`, regex-match `_('...', { ..., count: ... })`, for each base key verify both `<base>_one` and `<base>_other` exist in `en/translation.json`. (See conversation history for an implementation.)
|
||||
|
||||
**Where this bit us:**
|
||||
|
||||
- Initial bug: `_('{{count}} days', { count: n })` rendered "1 days" because en had no `_one` form.
|
||||
- Audit found 16 missing en plural keys from earlier PRs (OPDS / TTS / dictionary import) silently rendering wrong.
|
||||
- Then overcorrected and added non-plural keys like `Sign in to share books` to en/translation.json — wrong, breaks the project's convention. en stays clean for non-plural strings.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: gstack upgrade location
|
||||
description: Always upgrade gstack from the project directory (.claude/skills/gstack), not from a global install
|
||||
type: feedback
|
||||
---
|
||||
|
||||
When upgrading gstack, always run the upgrade from the current project's `.claude/skills/gstack` directory (local-git install), not from a global install path.
|
||||
|
||||
**Why:** The project uses a local-git gstack install at `apps/readest-app/.claude/skills/gstack`. Previous mistakes upgraded a global copy while the project's local copy stayed outdated.
|
||||
|
||||
**How to apply:** When `/gstack-upgrade` is invoked, ensure the `cd` and `git reset --hard origin/main && ./setup` happen inside the project's `.claude/skills/gstack` directory.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: No lookbehind regex
|
||||
description: Never use lookbehind assertions in JS/TS code — the build check rejects them for browser compatibility
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Never use lookbehind regex (`(?<=...)` or `(?<!...)`) in JavaScript/TypeScript source code. Use `(?:^|[^...])` or other alternatives instead.
|
||||
|
||||
**Why:** The project has a `check:lookbehind-regex` build check (`pnpm check:all`) that scans the Next.js output chunks and fails if any lookbehind assertions are found. Older WebViews (especially on some Android devices) don't support lookbehinds.
|
||||
|
||||
**How to apply:** When writing regex that needs to assert what comes before a match, use a non-capturing group with alternation (e.g., `(?:^|[^a-z-])`) instead of a negative lookbehind (`(?<![a-z-])`). This applies to all `.ts`/`.tsx`/`.js` files that end up in the build output.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: No test seams in production code
|
||||
description: Never import or call `__reset*ForTests` (or any test-only helper) from production modules — keep test orchestration on the test side
|
||||
type: feedback
|
||||
originSessionId: 49a72b36-8f45-4a57-87e1-e10563bac47a
|
||||
---
|
||||
Production code must never import or call functions named `__reset*ForTests`
|
||||
(or any other test-only seam). If a `__resetXForTests` function in module A
|
||||
needs to also clear state owned by module B, the test file is responsible
|
||||
for calling both resets — not module A's reset chaining into module B's.
|
||||
|
||||
**Why:** Importing a test-only helper into a production module pulls the
|
||||
test seam into the prod import graph, blurs the test/prod boundary, and
|
||||
risks the helper being shipped or mistakenly called at runtime. Caught
|
||||
once in `src/services/sync/replicaSync.ts` where
|
||||
`__resetReplicaSyncForTests` had been changed to call
|
||||
`__resetSettledEventsForTests` from `@/utils/event` for "convenience."
|
||||
|
||||
**How to apply:**
|
||||
- A `__resetXForTests` function should clear ONLY its own module's state.
|
||||
- If a test needs a coordinated reset across modules, do it in the test
|
||||
file's `beforeEach` / `afterEach` — call each module's seam directly.
|
||||
- Never `import { __reset...ForTests }` inside `src/` outside of
|
||||
`src/__tests__/`. A grep `grep -rn "__reset.*ForTests" src/ --include="*.ts" --include="*.tsx" | grep -v __tests__ | grep -v "^.*export const __reset"` should return zero hits.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Always use a new branch for new PRs
|
||||
description: Each new PR/issue should get its own fresh branch from main, never reuse an existing feature branch
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Always create a new branch from main for each new PR or issue. Never reuse an existing feature branch for unrelated work.
|
||||
|
||||
**Why:** The user corrected this when a storage fix was committed on the `feat/full-sync-annotations` branch instead of a dedicated branch. Mixing unrelated changes on the same branch makes PRs harder to review and manage.
|
||||
|
||||
**How to apply:** Before committing fixes, create a new branch like `fix/<topic>` from `origin/main`. Only reuse a branch if the work is directly related to that branch's existing purpose.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: Always rebase before PR
|
||||
description: Rebase to origin/main before creating pull requests
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Always rebase the branch onto origin/main before creating a pull request.
|
||||
|
||||
**Why:** The user wants PRs to be up-to-date with main to avoid merge conflicts and keep a clean history.
|
||||
|
||||
**How to apply:** Before running `gh pr create`, always run `git fetch origin && git rebase origin/main` first. If there are conflicts, resolve them before proceeding.
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
name: test-file-filter
|
||||
description: Use pnpm test/test:browser with path directly (no --) to run a single test file
|
||||
type: feedback
|
||||
---
|
||||
|
||||
Run a specific test file with `pnpm test <path>` or `pnpm test:browser <path>` — no `--` separator.
|
||||
|
||||
**Why:** Adding `--` before the path (e.g. `pnpm test:browser -- <path>`) causes vitest to ignore the file filter and run all test files. Without `--`, pnpm appends the path directly to the vitest command, which correctly filters to that file only.
|
||||
|
||||
**How to apply:** Always use `pnpm test src/__tests__/foo.test.ts` or `pnpm test:browser src/__tests__/foo.browser.test.tsx` when verifying a specific test file.
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Use worktree for PR/issue/feature work
|
||||
description: Always create a git worktree with pnpm worktree:new before reviewing PRs, fixing issues, or implementing features
|
||||
type: feedback
|
||||
originSessionId: 650f8ff2-980d-459f-ad23-ba0af56e28b5
|
||||
---
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create an isolated worktree before starting work on:
|
||||
- Reviewing a GitHub PR (e.g., `pnpm worktree:new 3809`) → worktree at `~/dev/readest-pr-3809`
|
||||
- Fixing a GitHub issue (e.g., `pnpm worktree:new fix/issue-123`) → worktree at `~/dev/readest-fix-issue-123`
|
||||
- Implementing a feature request (e.g., `pnpm worktree:new feat/my-feature`) → worktree at `~/dev/readest-feat-my-feature`
|
||||
|
||||
Worktree directory convention: `readest-<name>` in the parent of the repo root (`~/dev/`), with slashes replaced by dashes.
|
||||
|
||||
Use `pnpm worktree:rm <branch-name|pr-number>` to clean up when done.
|
||||
|
||||
**Why:** Keeps the current bare repo branch untouched. Each task gets its own isolated workspace with submodules, dependencies, env files, and vendor assets already set up.
|
||||
|
||||
**How to apply:** Before touching any code for a PR review, bug fix, or feature, run `pnpm worktree:new` first. Work inside the new worktree directory (e.g., `~/dev/readest-pr-3809/apps/readest-app/`). Clean up with `pnpm worktree:rm` after merging or finishing.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: foliate-touch-listener-capture-phase
|
||||
description: "To intercept/suppress reader touch gestures from the app, use capture-phase listeners — foliate-js's paginator registers bubble-phase doc listeners first"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 4b0bfcd2-a4ed-4b3c-99c2-b3c37ef7c530
|
||||
---
|
||||
|
||||
There are **three** independent touch-listener registrants on the foliate iframe `doc`:
|
||||
1. `FoliateViewer.tsx` (~line 326) — passive forwarders that only `postMessage`.
|
||||
2. `Annotator.tsx` (~line 332) — non-passive, drive text selection.
|
||||
3. **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`) — non-passive, **bubble-phase**, registered during `view.open()` (so *before* any app-level `load` handler). It can `preventDefault`, set `#touchScrolled`, `scrollBy`.
|
||||
|
||||
Consequence: a bubble-phase app listener registered "before the existing FoliateViewer listeners" **cannot** `stopImmediatePropagation` the paginator — the paginator already ran. Registration order only controls listeners within the same phase, and the paginator's are earlier regardless.
|
||||
|
||||
**Fix pattern:** register with `{ capture: true, passive: false }`. Capture-phase listeners on `doc` fire before all bubble-phase listeners when the event target is a descendant, so capture-phase `stopImmediatePropagation()` suppresses paginator + Annotator + FoliateViewer handlers alike. Scrolled mode also needs `preventDefault` from the first armed move (the paginator early-returns on `scrolled`, so native container scroll is what moves content).
|
||||
|
||||
Verified end-to-end for the [[brightness-swipe-gesture]] feature (test asserts a bubble-phase paginator stand-in never fires after a capture-phase `stopImmediatePropagation`). Both Codex and a Claude subagent independently confirmed against `paginator.js` during the /autoplan review.
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: issue-4112-scroll-anchoring
|
||||
description: "Root cause for readest#4112 scrolled-mode backward-nav bugs — scroll-anchoring suppressed at scrollTop 0 when prepending a section"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: e0b11058-53ee-4554-a518-134f788823ee
|
||||
---
|
||||
|
||||
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
|
||||
|
||||
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
|
||||
|
||||
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
|
||||
|
||||
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/3–2/3) because it races `#fillVisibleArea`'s reanchor. `primaryIndex` stays = target but the *visible* top section = target-1.
|
||||
|
||||
**Bug 2 (can't scroll up / jumps to beginning of prev section):** same suppression — prev section inserted above at scrollTop≈0 shifts viewport to the *beginning* of prev instead of staying put. Backward-preload is debounced-only (forward preload is eager/immediate) → asymmetry adds lag.
|
||||
|
||||
**FIX (landed on foliate-js branch `fix/scrolled-prev-prepend-anchor`, 2 changes in `#loadAdjacentSection` + `#goTo`):**
|
||||
1. Manual scroll compensation at the single prepend choke point `#loadAdjacentSection`: when prepending in scrolled mode (`index < sortedViews[0]`), after `await view.load()`, set `#renderedStart` to `startBefore + addedSize`. `containerPosition += (#vertical ? -1 : 1) * correction`; no-op (correction≈0) when the browser already anchored at scrollTop>0. Fixes drift (Bug 1) + scroll-up-shows-beginning (Bug 2b).
|
||||
2. The already-loaded `#goTo` branch only preloaded prev for *short* sections (`contentPages < columnCount`); changed to `needsPrev || this.scrolled` (+ `#isSameDirection` guard), mirroring `#display`. Fixes can't-scroll-up (Bug 2a) — the debounced backward-preload bails while `#stabilizing` after nav, so nav must preload prev itself.
|
||||
|
||||
**UX follow-ups (same branch, same file):**
|
||||
3. **No blank flash on adjacent nav**: the already-loaded `#goTo` branch faded the container `opacity 0→1`; in continuous scrolled mode that flashed (worse after change 2 put the prev-load inside the blank window). Now `blank = !this.scrolled || this.noContinuousScroll` — continuous scrolled scrolls straight to the (already-rendered) target. `loadPrev` helper: paginated loads prev BEFORE the scroll (fill leading columns), scrolled loads it AFTER (instant transition; compensation keeps position).
|
||||
4. **Eager backward preload**: removed the debounced, one-viewport-gated backward preload; added an eager one in the immediate scroll listener mirroring forward (`pagesBehind < minPages`, scrolled-gated). Fixes "scroll up dead-ends at top until you nudge down". Safe now because change 1 compensation handles position stability (the old "debounced to avoid cascade" reason is obsolete).
|
||||
|
||||
**Verified live + tests.** Scrolled regression tests live in `paginator-scrolled.browser.test.ts` (split out of the old `paginator-multiview.browser.test.ts`, which was renamed to `paginator-paginated.browser.test.ts` for the default/paginated + CFI tests). The 4 #4112 tests: drift / prev-preload-after-nav / no-blank / eager-backward-within-a-few-viewports (+ the moved 'columnCount=1 in scrolled mode' and the #3987 toggle-off test). `pnpm test` (4921) + `pnpm lint` + `pnpm format:check` + 57 paginator browser tests green (4 files: scrolled, paginated, expand, stabilization).
|
||||
|
||||
**GOTCHA for live verification:** programmatic `el.scrollTop = N` does NOT fire 'scroll' events in the claude-in-chrome context (real wheel/touch does). To test scroll-driven preloading via the JS console, set scrollTop AND `el.dispatchEvent(new Event('scroll'))`. Also: the Next.js 16 dev server bundles foliate-js (transpilePackages); editing paginator.js hot-reloads, but verify the served chunk has your edit (fetch `_next/static/chunks/packages_foliate-js_paginator_*.js` and grep) — recompile can lag.
|
||||
|
||||
readest-app test change is uncommitted on `dev`; foliate-js fix on branch `fix/scrolled-prev-prepend-anchor` (uncommitted) → needs a PR to readest/foliate-js then a submodule bump.
|
||||
|
||||
**Verification harness:** localhost:3000/reader/<id> book "凡人修仙传" (2470 sections). Expose `__pg`/`__fv`, tag view elements with section index via `iframe.contentDocument` identity, measure `visibleTopSec()` vs target after settle. jsdom CANNOT reproduce (no real layout); use Chrome.
|
||||
|
||||
Key file: `packages/foliate-js/paginator.js` (submodule, fork readest/foliate-js).
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: readest.koplugin i18n system
|
||||
description: Custom gettext loader, .po catalog layout, and extract/apply scripts for the KOReader plugin at apps/readest.koplugin/
|
||||
type: reference
|
||||
originSessionId: 08cfd0cd-b710-4674-9c90-d2ae4827d071
|
||||
---
|
||||
The KOReader plugin (`apps/readest.koplugin/`) has its own gettext-based i18n system, parallel to but separate from the readest-app i18next setup.
|
||||
|
||||
## Loader
|
||||
|
||||
- File: `apps/readest.koplugin/i18n.lua` — isolated module, returns a callable table via `setmetatable({}, {__call = ...})`, so `_("msg")` syntax works as a drop-in replacement for `require("gettext")`. Provides `ngettext`/`pgettext`/`npgettext` too. Falls back to KOReader's native `gettext` for missing strings.
|
||||
- All Lua sources do `local _ = require("i18n")` (not `require("gettext")`).
|
||||
- **Never rename `i18n.lua` to `gettext.lua`** — it would shadow KOReader's module via `require("gettext")` and break the fallback chain (recursive require / never loaded).
|
||||
|
||||
## Catalog layout
|
||||
|
||||
- `apps/readest.koplugin/locales/<lang>/translation.po` — mirrors `apps/readest-app/public/locales/<lang>/translation.json` exactly, using **i18next-style codes** (`zh-CN`, not `zh_CN`).
|
||||
- The loader converts KOReader's locale (e.g. `zh_CN.utf8` → `zh-CN`) before lookup.
|
||||
- Fallback chain: full code → base lang (`pt_BR` → `pt`) → `zh-CN` for any unspecified zh variant.
|
||||
- Language list is the single source of truth at `apps/readest-app/i18next-scanner.config.cjs` (`options.lngs`) — currently 31 languages.
|
||||
|
||||
## Scripts (in `apps/readest.koplugin/scripts/`)
|
||||
|
||||
- **`extract-i18n.js`** — primary tool. Run with `node scripts/extract-i18n.js`. Scans `*.lua` for `_("...")`, `_('...')`, and `_([[...]])` (with proper Lua-escape handling), reads each `.po`, **preserves existing translations**, adds new msgids with empty `msgstr`, drops obsolete msgids. Idempotent.
|
||||
- **`apply-translations.js`** — bulk applier. Reads `/tmp/koplugin-translations/<lang>.json` files (key = msgid, value = translation) and fills empty `msgstr ""` lines only — **never overwrites** existing translations.
|
||||
|
||||
## Workflow for adding/changing strings
|
||||
|
||||
1. Edit Lua source(s). Use `_("Foo")` or `T(_("Foo %1"), arg)` (`T` from `require("ffi/util").template`) — **never** `_("Foo ") .. arg`, because RTL/verb-final languages can't reorder the placeholder.
|
||||
2. `node scripts/extract-i18n.js` — adds new empty msgids, drops obsolete.
|
||||
3. To translate: drop `<lang>.json` files into `/tmp/koplugin-translations/`, then `node scripts/apply-translations.js`.
|
||||
4. Verify with `luac -p apps/readest.koplugin/*.lua` and re-run `extract-i18n.js` (should report no changes — idempotency check).
|
||||
|
||||
## Translation conventions
|
||||
|
||||
- Brand names "Readest" and "KOReader" stay untranslated.
|
||||
- Technical terms ("PDF", "API", "URL", "Supabase", "Hash") generally kept as-is, sometimes transliterated in non-Latin scripts.
|
||||
- Dialog title = title case (`Sync Info`); menu item label = sentence case (`Sync info`).
|
||||
- Lower-confidence translations (bo, si, ta, bn, sl, fa) deserve native-speaker review.
|
||||
|
||||
## Storage conventions for plugin state
|
||||
|
||||
- Global plugin state: `G_reader_settings:saveSetting("readest_sync", settings)` — login tokens, auto-sync flag, etc.
|
||||
- Per-book state: `ui.doc_settings:readSetting("readest_sync")` table — keys like `meta_hash_v1`, `last_synced_at_config`, `last_synced_at_notes` (seconds since epoch).
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: kosync-cfi-spine-resolution
|
||||
description: "KOSync CFI↔XPointer conversion must resolve via the CFI's own spine index, not the paginator's primaryIndex (which lags during scroll)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 41b15d60-969d-4173-9db7-d1515721e527
|
||||
---
|
||||
|
||||
KOSync progress push/pull converts between foliate CFI and KOReader XPointer via `src/utils/xcfi.ts`. `XCFI` is constructed for ONE spine section (`spineItemIndex`) and `cfiToXPointer`/`xPointerToCFI` throw `CFI spine index N does not match converter spine index M` if asked to convert a CFI from a different section.
|
||||
|
||||
**Pitfall:** `view.renderer.primaryIndex` lags behind the viewport during scrolling (see `paginator.js` `#primaryIndex` comment). So `progress.location` can be a CFI in a different spine section than the currently-rendered primary view. Building the converter from the primary view's `doc`/`index` then converting `progress.location` throws on mismatch.
|
||||
|
||||
**Fix (PR branch fix/kosync-spine-index):** always go through the exported helpers `getXPointerFromCFI(cfi, doc?, index?, bookDoc)` / `getCFIFromXPointer(...)` in `xcfi.ts`. They call `XCFI.extractSpineIndex(cfi)` and, when the passed `index` ≠ the CFI's spine, load the correct section's document via `bookDoc.sections[xSpineIndex].createDocument()`. `generateKOProgress` in `src/app/reader/hooks/useKOSync.ts` had hand-rolled `new XCFI(primaryDoc, primaryIndex)` instead of using the helper — that was the bug.
|
||||
|
||||
**Why:** the helper already handles cross-section resolution correctly; never reconstruct the converter inline against `primaryIndex`.
|
||||
|
||||
**How to apply:** for any CFI→XPointer or XPointer→CFI in KOSync code, use the `getXPointerFromCFI`/`getCFIFromXPointer` helpers and pass `bookDoc` so off-screen sections can be loaded. Related: [[issue-4112-scroll-anchoring]] (same primaryIndex/scroll-lag family). The companion foliate `fromRange` crash (`Cannot destructure property 'nodeType'`) during relocate is separate scroll-lag noise, not the sync blocker.
|
||||
|
||||
**Conflict-detection comparison (`promptedSync` in `useKOSync.ts`):** KOReader's reported `remote.percentage` comes from CREngine pagination and is NOT comparable to Readest's progress. For reflowable books, convert the remote XPointer → local CFI → `view.getCFIProgress(cfi).fraction` (helper `getRemoteLocalFraction` in `kosyncProgress.ts`) and compare THAT to the local percentage; fall back to `remote.percentage` only when it can't be resolved locally (non-XPointer/Kavita progress or missing section). The resolved fraction also drives the "Approximately X%" remote preview so the dialog shows what was actually compared. Fixed-layout still compares page-derived `remotePercentage`. Threshold: `0.0001` cross-device, but loosened to `0.01` when `remote.device_id === settings.kosync.deviceId` (same device = our own earlier push; don't prompt on sub-page drift). Percentages render via `formatProgressPercentage` (2 decimals, `kosyncPreview.ts`).
|
||||
@@ -1,70 +0,0 @@
|
||||
# Layout & UI Fixes Reference
|
||||
|
||||
## Safe Area Insets
|
||||
|
||||
### Architecture
|
||||
- Native plugins push inset values: iOS (`NativeBridgePlugin.swift`), Android (`NativeBridgePlugin.kt`)
|
||||
- `useSafeAreaInsets` hook (`src/hooks/useSafeAreaInsets.ts`) reads and caches values
|
||||
- Components use `gridInsets` for positioning relative to safe areas
|
||||
- CSS: `env(safe-area-inset-top/bottom/left/right)` for CSS-level insets
|
||||
|
||||
### Common Issues
|
||||
- **Stale insets after system UI change** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
|
||||
- **iPad sidebar insets wrong** (#3395): Different inset handling needed for sidebar vs main view
|
||||
- **Android nav bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for Android bottom padding
|
||||
- **Zoom controls behind status bar** (#3426): Pass `gridInsets` through component chain, use `Math.max(gridInsets.top, statusBarHeight)`
|
||||
|
||||
### Rules (see also `docs/safe-area-insets.md`)
|
||||
- Always pass `gridInsets` to overlay/floating components near screen edges
|
||||
- On Android, account for system navigation bar with `env(safe-area-inset-bottom)`
|
||||
- After toggling system UI visibility, force-refresh insets via `onUpdateInsets()`
|
||||
|
||||
## Z-Index Hierarchy
|
||||
- Navigation buttons: `z-10` (when visible)
|
||||
- Annotation nav bar: `z-10` (reduced from z-30 in #3386)
|
||||
- TTS control button: ensure above page nav buttons
|
||||
- Floating overlays: check against `gridInsets` positioning
|
||||
|
||||
## macOS Traffic Lights
|
||||
- Managed by `trafficLightStore.ts` and `HeaderBar.tsx`
|
||||
- **Fullscreen check required** (#3129): `await currentWindow.isFullscreen()` before hiding
|
||||
- **Timeout for visibility toggle** (#3488): Use 100ms delay to prevent flickering
|
||||
- **Sidebar interaction** (#3488): Check `getIsSideBarVisible()` before hiding traffic lights
|
||||
- Never hide traffic lights when sidebar is open
|
||||
|
||||
## Touch/Input Issues
|
||||
- **Slider hit area on iOS** (#3382): Use min-h-12, strip browser appearance with CSS
|
||||
- **Context menu on macOS** (#3324): 100ms delay before `onContextMenu()` to let pointer events complete
|
||||
- **Swipe sensitivity** (#3310): Use average velocity (distance/time) instead of instantaneous velocity for non-animated paging
|
||||
- **Touchpad natural scrolling** (#3127): Respect system natural scrolling setting in `usePagination.ts`
|
||||
|
||||
## Dialog/Menu Layout
|
||||
- **Dialog header** (#3352): Use `px-2 sm:pe-3 sm:ps-2` padding to align with border radius
|
||||
- **Settings alignment** (#3151): Use `Menu` component instead of raw `div` for consistent styling
|
||||
- **Dropdown for screen readers** (#3035): Use `DropdownContext` with overlay dismiss, not blur-based closing
|
||||
|
||||
## Component-Specific Fixes
|
||||
|
||||
### HeaderBar.tsx
|
||||
- Traffic light visibility management
|
||||
- Sidebar toggle persistent position (#3193)
|
||||
- Library button placement
|
||||
|
||||
### PageNavigationButtons.tsx
|
||||
- z-10 when visible (#3201)
|
||||
- Always shown for screen readers (#3036)
|
||||
- Toggle via `showPageNavButtons` setting
|
||||
|
||||
### ProgressInfo.tsx
|
||||
- Use physical `page`/`pages` from renderer, not estimated values (#3213, #3200)
|
||||
- CSS classes: `time-left-label`, `pages-left-label`, `progress-info-label` (#3343)
|
||||
|
||||
### ReadingRuler.tsx
|
||||
- Remove `containerStyle` from overlay so dimmed area covers full screen (#3304)
|
||||
|
||||
### NavigationBar.tsx
|
||||
- Handle `gridInsets` internally, not via pre-computed `navPadding` (#3466)
|
||||
|
||||
### ContentNavBar.tsx (annotation search results)
|
||||
- Floating buttons with drop shadow, not full-width bar (#3386)
|
||||
- z-10 z-index
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: manage-cache-ios-layout
|
||||
description: "iOS app container layout and what the Manage Cache feature can/can't clear"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 3512356b-a453-42d3-99f6-1ca43d06dd1e
|
||||
---
|
||||
|
||||
Manage Cache feature: `src/utils/cache.ts` (helpers `getCacheEntries`/`getCacheStats`/`clearCacheEntries` over `CacheSource[]`), `src/app/library/components/CacheManagerWindow.tsx` (singleton dialog, `setCacheManagerDialogVisible`, `getCacheSources()` composes the source list), menu item in `SettingsMenu.tsx` Advanced Settings, mounted in `library/page.tsx`.
|
||||
|
||||
Scope: **native mobile apps only** (gated on `appService?.isMobileApp`; hidden on desktop + web). Sources cleared: iOS → Cache + Temp + `Documents/Inbox`; Android → Cache + Temp.
|
||||
|
||||
On-device analysis (dev build `com.bilingify.readest`, pulled via `xcrun devicectl device copy from --domain-type appDataContainer`):
|
||||
|
||||
- The `'Cache'` base = Tauri `appCacheDir()` = **`Library/Caches/com.bilingify.readest`** only — NOT all of `Library/Caches`. On the test device this held ~272 MB: a 249 MB duplicate dictionary (`concise-enhanced.mdx`, canonical copy lives in `App Support/Readest/Dictionaries/<id>/`), ~23 MB duplicate import-staged epubs (canonical in `App Support/Readest/Books/<hash>/`), the `search/` results cache, plus tiny system scratch. All safe to clear — every large item is an orphaned import/download staging duplicate.
|
||||
- iOS open-in leftovers: **`Documents/Inbox`** (resolved via `documentDir()` + join `Inbox`, scanned/cleared with base `'None'` + absolute paths). Already-imported books linger here. The feature now clears it on iOS. (`tmp/<bundle>-Inbox` also exists but was empty.)
|
||||
- NOT reachable by the `'Cache'` base: `Library/Caches/WebKit` (~173 MB, WKWebView disk cache — needs native `WKWebsiteDataStore.removeData`) and `tmp/` blob scratch (~59 MB, maps to `'Temp'` base, not currently cleared). These are the bigger "free up space" wins if the feature is ever expanded.
|
||||
- Never clear `Library/Application Support/com.bilingify.readest/Readest` — the real Books/Dictionaries/Fonts/DB (~1.7 GB).
|
||||
|
||||
Root-cause follow-up worth doing: the import pipeline leaves staging copies in the cache root and `Documents/Inbox` instead of deleting them after import.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: paginator-swipe-bg-flash
|
||||
description: Swipe page-turn background flash —
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 374255ae-fbfd-4933-bc47-555e541fa115
|
||||
---
|
||||
|
||||
Swipe page-turn flash (white↔black pages) in the multiview paginator. Only on
|
||||
**swipe + animation** (not arrow keys, not animation-off). Repro: mobile
|
||||
emulator single column, swipe between a transparent page (colour comes from the
|
||||
host behind, e.g. a white cover) and a CSS-coloured page (e.g. `body{background:#000!important}`).
|
||||
`page2→page1` (black→white, backward) is 100%. Tell-tale: **slow swipe = big
|
||||
flash, quick swipe = small flash on the trailing side** (flash width == drag distance).
|
||||
|
||||
**Root cause.** `#background` (foliate-js `paginator.js`) was a static
|
||||
screen-space layer (a `repeat(cc,1fr)` grid coloured by each column's midpoint).
|
||||
Doc bodies are set transparent (`doc.body.style.background='none'`), so a page's
|
||||
full-bleed colour is painted by `#background`, NOT the iframe — except pages that
|
||||
force their colour with `!important`, which keep it in-iframe. During a swipe the
|
||||
content moves but `#background` did not: (1) the **drag** scrolls via `scrollBy`
|
||||
with no `#replaceBackground` call (the debounced scroll handler doesn't fire
|
||||
mid-drag), so the incoming page rendered over the *outgoing* page's stale colour;
|
||||
(2) the **snap** pre-set the background to the *destination* (`#replaceBackground(offset)`)
|
||||
then slid only the content for 300ms, so the outgoing page lost its colour
|
||||
instantly and flashed across the area it still covered. Arrow keys don't flash
|
||||
because they start from rest (content already aligned with the pre-set destination).
|
||||
|
||||
**Fix** (both phases needed — drag-only fix leaves the snap flash):
|
||||
- `computeBackgroundSegments(views, scrollPos, bgSize, inset, containerSize)` —
|
||||
exported pure helper. One full-bleed segment per rendered view at
|
||||
`inset + viewOffset - scrollPos`; transparent (`bg===''`) views get no segment
|
||||
(host/theme shows through); segments meeting a container edge stretch into the
|
||||
full-bleed gutter. `#background` is now `position:relative; overflow:hidden`
|
||||
with absolutely-positioned segment divs (not a grid).
|
||||
- Drag: rebuild every scroll in the container `scroll` listener, gated on
|
||||
`!this.scrolled && !this.#isAnimating`.
|
||||
- Snap (`#scrollTo`): build at the **current** position, then a per-rAF
|
||||
`syncBackground` loop reads the animated view's transform
|
||||
(`new DOMMatrix(getComputedStyle(child).transform).m41`, m42 for vertical) and
|
||||
calls `#replaceBackground(startPosition - tx)` so segments track the content
|
||||
every frame. Per-frame rebuild (not a CSS translate) because the incoming
|
||||
page's segment must *grow* as it slides in.
|
||||
|
||||
Key insight: `expand()` makes a view element exactly `contentPages*columnSize`
|
||||
wide, so per-view segments == the old per-column grid at rest, but they can slide.
|
||||
|
||||
Test: `src/__tests__/document/paginator-background-segments.test.ts` (pure helper).
|
||||
Visual repro = synthetic `Touch` events via Chrome MCP + an rAF timeline sampling
|
||||
`#background` segment `{left,width,bg}` + the view transform through drag AND snap.
|
||||
foliate-js is a submodule — commit there + bump the pointer. See [[issue-4112-scroll-anchoring]].
|
||||
@@ -1,91 +0,0 @@
|
||||
# Platform Compatibility Fixes Reference
|
||||
|
||||
## Android
|
||||
|
||||
### WebView API Issues
|
||||
- **`navigator.getGamepads()`** returns null on older WebView (#3245): Always null-check before `.some()`
|
||||
- **`CompressionStream`** unavailable on some Android WebView versions (#3255): Add fallback zip compression path
|
||||
- **Annotation tools unresponsive** (#3225): Don't call `makeSelection()` immediately on pointer-up; let popup flow complete naturally
|
||||
- **Safe inset updates** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
|
||||
- **Navigation bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for bottom padding
|
||||
|
||||
### General Android Rules
|
||||
- Test with older WebView versions (97+)
|
||||
- Always check API availability before calling Web APIs
|
||||
- Touch event handling differs from iOS - avoid premature re-selection
|
||||
|
||||
## iOS
|
||||
|
||||
### Common Issues
|
||||
- **Slider touch dead zones** (#3382): Strip native appearance, use larger hit areas (min-h-12)
|
||||
- **Safe area insets stale** (#3395): Native Swift plugin must push updated insets
|
||||
- **Section content caching** (#3242, #3206): Don't cache section content in foliate-js when updating subitems; cached content retains stale styles after mode switch
|
||||
- **CompressionStream** (#3255): Also broken on iOS 15.x; zip.js has its own native API disable
|
||||
- **zip.js native API** (#3170): Disable native `CompressionStream`/`DecompressionStream` on iOS 15.x
|
||||
|
||||
### iPad reports a desktop UA → never branch native dispatch on `getOSPlatform()`
|
||||
- `getOSPlatform()` (utils/misc) is **user-agent based**, and iPadOS sends a desktop "Macintosh" UA → it returns `'macos'` on iPad. Any native-OS dispatch keyed on it misroutes iPad to the macOS path.
|
||||
- Symptom seen: system dictionary on iPad threw `"Command show_lookup_popover not found"` — the macOS-only Rust command (`src-tauri/src/macos/system_dictionary.rs`); iOS only registers the plugin command `plugin:native-bridge|show_lookup_popover`.
|
||||
- **Rule:** for OS-specific native dispatch/capability, use `appService.isIOSApp / isMacOSApp / isAndroidApp` (derived from the Tauri OS plugin `type()` → `OS_TYPE` in `nativeAppService.ts`), NOT `getOSPlatform()`. The misc.ts comment says this explicitly.
|
||||
- Sync, non-React modules: `getInitializedAppService()` (environment.ts) returns the cached singleton synchronously (null pre-init). Used by `systemDictionary.ts` for `isSystemDictionarySupported()` (sync) + `invokeSystemDictionary()` (async).
|
||||
|
||||
### iOS-Specific Code
|
||||
- `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift`
|
||||
- Slider CSS: `-webkit-appearance: none; appearance: none` in globals.css
|
||||
- `useSafeAreaInsets.ts` hook
|
||||
|
||||
## macOS
|
||||
|
||||
### Traffic Lights (Window Controls)
|
||||
- Check `isFullscreen()` before hiding (#3129)
|
||||
- Use timeouts (100ms) for visibility transitions (#3488)
|
||||
- Don't hide when sidebar is open (#3488)
|
||||
|
||||
### Input Issues
|
||||
- **Context menu steals event loop** (#3324): 100ms setTimeout before calling menu.popup()
|
||||
- **Touchpad natural scrolling** (#3127): Respect system setting in `usePagination.ts`
|
||||
- **Two-finger swipe** (#3127): Support trackpad two-finger swipe for pagination
|
||||
|
||||
### macOS-Specific Code
|
||||
- `src-tauri/src/macos/` - Platform-specific Rust code
|
||||
- `src/store/trafficLightStore.ts`
|
||||
- `src/hooks/useTrafficLight.ts`
|
||||
|
||||
## Linux
|
||||
|
||||
### WebKitGTK Issues
|
||||
- **View Transitions API unsupported** (#3417): Feature-detect `document.startViewTransition` before calling
|
||||
- Use `useAppRouter.ts` to avoid transitions on Linux
|
||||
|
||||
## E-ink Devices
|
||||
|
||||
### Legibility Issues
|
||||
- **Low contrast colors** (#3258): Use `not-eink:` Tailwind variant prefix for colors/opacity
|
||||
- **Links invisible** (#3258): Don't apply `text-primary` (blue) on e-ink; use default text color
|
||||
- **Opacity too low** (#3258): Don't apply `opacity-60`/`opacity-75` on e-ink devices
|
||||
- **Highlight visibility** (#3299): Use foreground color for highlights in dark mode e-ink
|
||||
|
||||
### E-ink CSS Pattern
|
||||
```
|
||||
// Instead of:
|
||||
className="text-primary opacity-60"
|
||||
// Use:
|
||||
className="not-eink:text-primary not-eink:opacity-60"
|
||||
```
|
||||
|
||||
## Docker/Self-Hosting
|
||||
- **Missing submodules** (#3233): Run `git submodule update --init --recursive` before build
|
||||
- Simplecc WASM module must be initialized
|
||||
|
||||
## OPDS
|
||||
- **Non-ASCII credentials** (#3436): Use `TextEncoder` + manual Base64 instead of `btoa()`
|
||||
- **Author parsing** (#3120): Handle varied metadata structures in OPDS 2.0 feeds
|
||||
- **Responsive layout** (#3418): Ensure catalog and download button layout works on small screens
|
||||
|
||||
## Cross-Platform Testing Checklist
|
||||
1. Android (old WebView + current)
|
||||
2. iOS (15.x + current)
|
||||
3. macOS (traffic lights, trackpad)
|
||||
4. Linux (WebKitGTK)
|
||||
5. E-ink devices (contrast, colors)
|
||||
6. Web (CloudFlare Workers deployment)
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: reading-ruler-line-aware
|
||||
description: "Line-aware/column-aware reading ruler — geometry pipeline, key files, and the Range.getClientRects block-box gotcha"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f55c9971-c85f-44af-82c3-822e3bbd1129
|
||||
---
|
||||
|
||||
Reading ruler (`src/app/reader/components/ReadingRuler.tsx`, pure logic in
|
||||
`src/app/reader/utils/readingRuler.ts`) snaps to real rendered text lines instead of
|
||||
stepping by a fixed arithmetic height. MERGED to main via PR #4358 (squash); the
|
||||
`feat/line-aware-reading-ruler` branch + worktree are gone. Spec/plan under
|
||||
`apps/readest-app/docs/superpowers/`.
|
||||
|
||||
Geometry pipeline:
|
||||
- Line geometry comes from `progress.range.getClientRects()` (the foliate relocate range
|
||||
spans first-to-last visible text). Single column + vertical mode → `buildLineBoxes` +
|
||||
`snapReadingRulerToLines` (full-width band). Multi-column horizontal → `buildReadingRulerColumns`
|
||||
+ `snapReadingRulerColumns`, band spans one column, dims the rest incl. the other column.
|
||||
- The snap functions return the real line-block extent `{start,end}`; the band is sized
|
||||
dynamically to that block + symmetric padding (`calculateReadingRulerPadding` =
|
||||
`round(fontSize*lineHeight*0.4)`) so padding is equal all around. `currentPosition` stays
|
||||
the band center % (drag/persistence); `bandSize` state holds the dynamic thickness, falling
|
||||
back to `baseRulerSize + 2*padding` for scrolled/fixed-layout/drag/unmeasured.
|
||||
- Multi-column detection: `view.renderer.columnCount` (already on the `Renderer` type).
|
||||
- Snapping works in scrolled mode too (`supportsLineSnap` no longer excludes scrolled).
|
||||
Single-column geometry goes through `buildVisibleLineBoxes` → frame-offset mapping, which
|
||||
is REQUIRED in scrolled mode (the visible section iframe is offset vertically by the scroll;
|
||||
there are several stacked section iframes, frame tops far negative). Auto-move stays disabled
|
||||
in scrolled mode (no jump on scroll); the band derives from the saved position on mount.
|
||||
- GOTCHA: in scrolled mode `progress.range` (the relocate range) covers only PART of the
|
||||
viewport, so using it would make the band hit a false end mid-view and scroll early (skipping
|
||||
lines). Scrolled mode instead builds boxes from the visible section(s) directly
|
||||
(`buildScrolledLineBoxes`: walk `view.renderer.getContents()`, `selectNodeContents(body)`,
|
||||
frame-offset map, filter to viewport). At a view edge a tap sets `pendingScrollAlignRef`; the
|
||||
view scrolls and the next relocate realigns the band to the first (forward) / last (backward)
|
||||
group. `filterVisibleLineBoxes` (≥0.5 visible) keeps the mount/realign placement on-screen.
|
||||
- GOTCHA: do NOT call `renderer.scrollBy()` yourself to advance the band in scrolled mode — a
|
||||
manual scroll that crosses a section boundary fires the relocate MID-relayout, so
|
||||
`getContents()` frame offsets are stale and `buildScrolledLineBoxes` returns garbage (huge
|
||||
blocks spanning gaps) → the band loops in place. Instead, when the next block can't be
|
||||
centered, return false so foliate page-scrolls (its relocate fires AFTER layout settles) and
|
||||
realign via `pendingScrollAlignRef`. Band is always centered on its block (no center clamp).
|
||||
- Band height is capped at `calculateReadingRulerSize(lines + 1, …)` so a tall element (e.g. a
|
||||
full-page image) inside the snapped block can't expand the band to cover the whole image.
|
||||
- **Coordinate mapping**: paginated multi-column pages shift the iframe far off-screen
|
||||
horizontally (`frameRect.left` was -4773 in testing); map iframe-content rects to overlay
|
||||
coords with the iframe `frameElement` offset (`mapRangeRectsToOverlay`), not just
|
||||
`rect.top - containerRect.top`.
|
||||
|
||||
**GOTCHA (caused a paragraph-skip bug):** `Range.getClientRects()` aggregates the border
|
||||
boxes of every *fully-enclosed element*, so multi-line `<p>`/container blocks return rects
|
||||
far taller than a text line (e.g. h=410 spanning a whole paragraph) alongside the line
|
||||
rects. An overlap-based line merge chains those into one giant "line", making the snap skip
|
||||
an entire paragraph — worse with more paragraphs per column. Fix: `dropBlockRects()` discards
|
||||
rects whose cross-axis thickness > 1.8× the median line thickness before clustering.
|
||||
|
||||
**Vertical key mapping**: in vertical writing mode only Up/Down arrows move the ruler;
|
||||
Left/Right turn pages (`isReadingRulerMoveKey(side, isVertical)` in `readingRuler.ts`, gating
|
||||
`moveReadingRuler` in `useBookShortcuts.ts`). Taps always move the ruler regardless of the
|
||||
tapped side (the restriction is keyboard-only; the tap path in `usePagination.ts` is untouched).
|
||||
|
||||
**Scrolled vertical-rl gotcha**: scrolled vertical scrolls HORIZONTALLY (section iframes stack
|
||||
along x; all share top/bottom). `progress.range` covers only the left ~30% of the visible width,
|
||||
so building boxes from it makes the edge realign land mid-view. `buildScrolledLineBoxes` is now
|
||||
vertical-aware (horizontal frame-visibility filter `frame.right<=cont.left || frame.left>=cont.right`,
|
||||
`buildLineBoxes(mapped, isVertical, …)`) and the scrolled edge-scroll path (`buildScrolledLineBoxes`
|
||||
choice, centerable check) no longer excludes vertical (`scrolled = !!viewSettings.scrolled`, was
|
||||
`&& !isVertical`). At the last column, advancing scrolls the view forward and snaps the band to the
|
||||
first (rightmost) column; backward snaps to the last (leftmost). PAGINATED vertical already paged at
|
||||
the boundary (snap returns null → handler returns false → keyboard `viewPagination` → `view.next()`;
|
||||
auto-move effect places the band on the new page's first column).
|
||||
|
||||
Verified live with Chrome MCP by reconstructing the column pipeline in-page and dispatching
|
||||
ArrowRight/ArrowLeft. Note: rapid synthetic key events in a tight loop get coalesced/throttled
|
||||
by the reader's nav handling — drive one press per tool call with real time between. To inspect
|
||||
logged objects (read_console_messages shows "Object"), monkey-patch console.log in-page to push
|
||||
JSON.stringify'd args into a window array, then read that array.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: Share-a-Book Feature (in progress)
|
||||
description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path.
|
||||
type: project
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing).
|
||||
|
||||
**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top.
|
||||
|
||||
**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing.
|
||||
|
||||
## Locked decisions (authoritative)
|
||||
|
||||
- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction.
|
||||
- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are.
|
||||
- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`.
|
||||
- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged.
|
||||
- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create.
|
||||
- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically.
|
||||
- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts.
|
||||
- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write.
|
||||
- **Per-user 50-share cap**, enforced at create-time.
|
||||
- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails.
|
||||
- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern.
|
||||
- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host.
|
||||
|
||||
## Cherry-picks accepted (CEO review SELECTIVE EXPANSION)
|
||||
|
||||
1. **Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context).
|
||||
3. **1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`.
|
||||
4. **Branded OG image** — `/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist.
|
||||
|
||||
Deferred to TODOS.md: QR code on landing, notify-on-download toggle.
|
||||
|
||||
## Open implementation questions (resolve mid-build)
|
||||
|
||||
1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD.
|
||||
2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file.
|
||||
3. `@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible.
|
||||
4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build.
|
||||
|
||||
## Critical files for implementation
|
||||
|
||||
See "Critical Files (modify or create)" table in the plan. Key starting points:
|
||||
- `src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts`
|
||||
- `src/libs/share-server.ts` (new) — `generateShareToken()`, `hashShareToken(raw)`
|
||||
- `src/libs/share.ts` (new) — typed client used by dialog, manage section, deeplink hook, landing page
|
||||
- `src/utils/share.ts` (new) — `buildShareUrl(token)`, `parseShareDeepLink(url)`
|
||||
- Dialog reference: `src/components/Dialog.tsx`, `BookDetailModal.tsx`
|
||||
- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`)
|
||||
- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts`
|
||||
- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn)
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: toc-expand-and-autoscroll
|
||||
description: TOC sidebar auto-expansion policy + the Virtuoso scrollToIndex-after-expansion race that breaks the initial scroll-to-current-chapter
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 4d4eefcb-324a-4342-9472-9dae7cc57b41
|
||||
---
|
||||
|
||||
Issue #4059: the TOC sidebar used to auto-expand *every* top-level container (`computeExpandedSet` added all `toc.filter(item => item.subitems?.length)`), so multi-volume books ("文学必读合集20册") opened fully expanded. Fix: expand only the current location's ancestor path (`findParentPath`), with a single-root fallback (expand `toc[0]` only when `toc.length === 1`) so a one-root-wrapper TOC isn't reduced to one row. Pure logic lives in `src/app/reader/components/sidebar/tocTree.ts` (extracted from TOCView so it's unit-testable — TOCView itself can't be imported in jsdom: react-virtuoso + OverlayScrollbars CSS).
|
||||
|
||||
Collapsing-by-default introduced a **regression**: the current chapter no longer scrolled into view on fresh load. Root cause is two-fold, both stemming from the pinned sidebar mounting *before* the first relocate (progress is null at mount, so `initialScrollTarget.index` is 0 and Virtuoso's `initialTopMostItemIndex` can't anchor):
|
||||
|
||||
1. When progress arrives, the current volume expands and the virtualized list grows by dozens of rows in one commit. Virtuoso emits a synthetic `onScroll`; the old handler treated *any* scroll while a scroll was queued as "user took over" and cleared `pendingScrollRef`. Fix: ignore a queued-state scroll unless a real gesture preceded it — track `userInputRef` via capture-phase `wheel`/`touchstart`/`pointerdown`/`keydown` listeners on the scroller; `if (pendingScrollRef.current && !userInputRef.current) return;`.
|
||||
2. Even with pending preserved, a single `scrollToIndex(idx, {align:'center'})` fired in the same commit as the 28→90 row growth **lands short** — Virtuoso scrolls before measuring the new rows. Fix: re-assert the scroll on the next `requestAnimationFrame` (only for the instant `behavior:'auto'` case). The toggle-sidebar (remount) path always worked because remounting feeds `initialTopMostItemIndex` before first render.
|
||||
|
||||
Verified live: 红楼梦 book at `/reader/217e3f1e...` — only 下卷 expanded, current chapter centered (0px from viewport center), user scroll no longer snaps back. Related: [[issue-4112-scroll-anchoring]], [[reading-ruler-line-aware]], [[virtuoso_overlayscrollbars]].
|
||||
@@ -1,50 +0,0 @@
|
||||
# TTS (Text-to-Speech) Fixes Reference
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
- `TTSController` (`src/services/tts/TTSController.ts`) - Core state machine
|
||||
- `EdgeTTSClient` (`src/services/tts/EdgeTTSClient.ts`) - Edge TTS provider
|
||||
- `useTTSControl` hook (`src/app/reader/hooks/useTTSControl.ts`) - React integration
|
||||
- `useTTSMediaSession` hook (`src/app/reader/hooks/useTTSMediaSession.ts`) - Media controls
|
||||
|
||||
### Section-Aware TTS Model
|
||||
TTS tracks its own section independently from the view via `#ttsSectionIndex`:
|
||||
- `#initTTSForSection()` - Creates TTS document for a section without changing the view
|
||||
- `#initTTSForNextSection()` / `#initTTSForPrevSection()` - Navigate TTS across sections
|
||||
- `#getHighlighter()` - Only returns highlighter if view section matches TTS section
|
||||
- `onSectionChange` callback - Notifies UI when TTS crosses section boundary
|
||||
- Highlights use CFI strings (not raw Range objects) for cross-section compatibility
|
||||
|
||||
### State Management Pitfalls
|
||||
1. **`#ttsSectionIndex` must match view section for highlights to work**
|
||||
- If `-1`, all highlight calls are suppressed
|
||||
- `shutdown()` sets it to `-1` but must also null out `this.view.tts`
|
||||
|
||||
2. **Guards/Refs that block re-entry:**
|
||||
- The old `ttsOnRef` guard blocked TTS restart from annotations (removed in #3292)
|
||||
- `view.tts` reference surviving shutdown blocked re-initialization (#3400)
|
||||
|
||||
3. **Timeouts that fire after pause:**
|
||||
- Edge TTS had a safety timeout that advanced sentences even when paused (#3244)
|
||||
- Solution: removed the entire `ontimeupdate` safety timeout mechanism
|
||||
|
||||
## Fix History
|
||||
|
||||
| Issue | Problem | Root Cause | Fix |
|
||||
|-------|---------|------------|-----|
|
||||
| #3100 | TTS scrolls too far | TTS coupled to view section | Added `#ttsSectionIndex`, "Back to TTS Location" button |
|
||||
| #3198 | TTS doesn't follow to next section | No `onSectionChange` callback | Added section change notification, extracted hooks |
|
||||
| #3244 | Paused TTS advances | Safety timeout fires after pause | Removed `ontimeupdate` timeout mechanism |
|
||||
| #3291 | TTS fails without lang attribute | Invalid SSML from missing lang | Set lang/xml:lang on html element from `ttsLang` |
|
||||
| #3292 | Can't restart TTS from annotation | `ttsOnRef` blocks re-entry | Removed the guard ref entirely |
|
||||
| #3400 | TTS highlight stops after restart | `view.tts` not nulled on shutdown | Added `this.view.tts = null` in `shutdown()` |
|
||||
|
||||
## Debugging TTS Issues
|
||||
|
||||
1. **TTS doesn't start:** Check `#initTTSForSection()` - does `view.tts.doc === doc` shortcut early?
|
||||
2. **No highlights:** Check `#ttsSectionIndex` matches view's section index
|
||||
3. **Advances when paused:** Look for setTimeout/timer callbacks that bypass pause state
|
||||
4. **Can't restart:** Check for refs/guards that prevent re-entry into speak handlers
|
||||
5. **Fails on some chapters:** Check if chapter has lang attribute and XHTML namespace
|
||||
6. **SSML errors:** Check `src/utils/ssml.ts` for proper namespace/lang handling
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
name: Virtuoso + OverlayScrollbars pattern
|
||||
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
|
||||
type: reference
|
||||
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
|
||||
---
|
||||
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
|
||||
|
||||
## Migration from `customScrollParent`
|
||||
|
||||
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
|
||||
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
|
||||
- Remove the outer `OverlayScrollbarsComponent` wrapper
|
||||
- Use `scrollerRef` instead to capture Virtuoso's scroller element
|
||||
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
|
||||
|
||||
## Boilerplate
|
||||
|
||||
```tsx
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
// Inside the component:
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
// If parent needs the scroller (e.g. for pull-to-refresh):
|
||||
onScrollerRef?.(div as HTMLDivElement | null);
|
||||
}, [onScrollerRef]);
|
||||
```
|
||||
|
||||
## JSX structure
|
||||
|
||||
```tsx
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
<Virtuoso
|
||||
scrollerRef={handleScrollerRef}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={items.length}
|
||||
itemContent={renderItem}
|
||||
overscan={200}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
|
||||
|
||||
## Footer spacer
|
||||
|
||||
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
|
||||
|
||||
```tsx
|
||||
const VIRTUOSO_COMPONENTS = {
|
||||
List: MyListComponent,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
|
||||
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
|
||||
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
|
||||
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
|
||||
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
|
||||
|
||||
## Used in
|
||||
|
||||
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
|
||||
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
## Test-First Development
|
||||
|
||||
- Always write a failing unit test **before** implementing a fix.
|
||||
- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes.
|
||||
- Run the full test suite (`pnpm test`) after changes to ensure no regressions.
|
||||
@@ -1,5 +0,0 @@
|
||||
## TypeScript
|
||||
|
||||
- Never use the `any` type. Use `unknown`, proper types, or generics instead.
|
||||
- Strict mode is enabled. Target is ES2022.
|
||||
- Unused vars prefixed with `_` are allowed (ESLint configured).
|
||||
@@ -1,9 +0,0 @@
|
||||
## Verification (done-conditions)
|
||||
|
||||
Before marking work complete, all applicable checks must pass:
|
||||
|
||||
1. `pnpm test` — unit tests (vitest)
|
||||
2. `pnpm lint` — Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
|
||||
3. `pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
|
||||
4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
|
||||
5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
|
||||
Submodule apps/readest-app/.claude/skills/gstack deleted from 22f8c7f4e1
@@ -1,111 +0,0 @@
|
||||
---
|
||||
name: i18n-koplugin
|
||||
description: >
|
||||
Extract i18n strings from readest.koplugin Lua sources and translate empty
|
||||
msgstrs in apps/readest.koplugin/locales. Use when the user invokes
|
||||
/i18n-koplugin or asks to extract/translate koplugin i18n strings.
|
||||
Runs scripts/extract-i18n.js to sync .po catalogs from `_("...")` calls,
|
||||
then fills any empty `msgstr ""` entries across all locale files.
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
Extract/translate i18n strings for `readest.koplugin`. The catalogs are gettext `.po` files (not JSON like the main app). Run from the repo root or any worktree — the script resolves paths relative to the plugin dir.
|
||||
|
||||
## Step 1: Determine the working directory
|
||||
|
||||
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The plugin dir is `<repo-root>/apps/readest.koplugin`.
|
||||
|
||||
## Step 2: Extract msgids from Lua sources
|
||||
|
||||
```bash
|
||||
cd <repo-root>/apps/readest.koplugin
|
||||
node scripts/extract-i18n.js
|
||||
```
|
||||
|
||||
This scans every `*.lua` file under `apps/readest.koplugin/` (except `spec/` and dotdirs) for `_("...")` and `_([[...]])` calls, then for each language listed in `apps/readest-app/i18next-scanner.config.cjs`:
|
||||
|
||||
- appends new msgids with empty `msgstr ""`
|
||||
- preserves existing translations
|
||||
- drops obsolete msgids
|
||||
- rewrites the `.po` header (Plural-Forms etc.)
|
||||
|
||||
The output prints `<lang> <kept>/<total> (-<dropped> obsolete)` per locale.
|
||||
|
||||
## Step 3: Find untranslated entries
|
||||
|
||||
An untranslated entry is a non-empty `msgid` followed by an empty `msgstr ""` (the file's header pair `msgid ""` / `msgstr ""` is NOT a translation — skip it).
|
||||
|
||||
```bash
|
||||
cd <repo-root>/apps/readest.koplugin/locales
|
||||
# List locales that still have untranslated strings, with counts
|
||||
for f in */translation.po; do
|
||||
# Count empty msgstrs that follow a non-empty msgid
|
||||
n=$(awk '
|
||||
/^msgid "/ { msgid=$0; next }
|
||||
/^msgstr ""$/ { if (msgid != "msgid \"\"") c++; next }
|
||||
' "$f")
|
||||
[ "$n" -gt 0 ] && echo "$f: $n untranslated"
|
||||
done
|
||||
```
|
||||
|
||||
If no results, report that all strings are translated and stop.
|
||||
|
||||
To list the actual untranslated msgids in one locale:
|
||||
|
||||
```bash
|
||||
awk '
|
||||
/^msgid "/ { msgid=$0; next }
|
||||
/^msgstr ""$/ { if (msgid != "msgid \"\"") print msgid; next }
|
||||
' <repo-root>/apps/readest.koplugin/locales/<lang>/translation.po
|
||||
```
|
||||
|
||||
## Step 4: Translate empty msgstrs
|
||||
|
||||
For each empty `msgstr ""` found:
|
||||
|
||||
1. Read the preceding `msgid "..."` — that's the English source string.
|
||||
2. Identify the target locale from the file path (e.g., `locales/ja/translation.po` → Japanese; see table below).
|
||||
3. Provide an accurate translation. Use the locale reference table for the language; match the tone/terminology already used in the same file (check existing translated entries for context).
|
||||
4. Preserve `printf`-style placeholders verbatim: `%s`, `%d`, `%1$s`, `%(name)s`, etc.
|
||||
5. Preserve newlines as `\n`, tabs as `\t`, and escape `"` as `\"` and backslashes as `\\` inside the msgstr.
|
||||
|
||||
Edit the `.po` files directly with the Edit tool — do NOT use sed for this, because msgids may contain characters that confuse shell quoting. Each replacement should target the unique `msgid "<English>"\nmsgstr ""` block:
|
||||
|
||||
Old:
|
||||
```
|
||||
msgid "<English string>"
|
||||
msgstr ""
|
||||
```
|
||||
|
||||
New:
|
||||
```
|
||||
msgid "<English string>"
|
||||
msgstr "<translation>"
|
||||
```
|
||||
|
||||
Batch all locales for the same key together when possible — keeps the translation set consistent.
|
||||
|
||||
### Locale reference
|
||||
|
||||
The supported language set is **not hardcoded in this skill**. The ground truth is `apps/readest-app/i18n-langs.json` — both `i18next-scanner.config.cjs` (via `require`) and `src/i18n/i18n.ts` (via JSON import) source from it, and `extract-i18n.js` reads `lngs` from the scanner config at runtime. To list the current set:
|
||||
|
||||
```bash
|
||||
cat <repo-root>/apps/readest-app/i18n-langs.json
|
||||
```
|
||||
|
||||
Map each code to a language name when translating. If a code in `i18n-langs.json` is missing from the `LANG_META` table inside `extract-i18n.js`, the script prints `<code> skipped (no metadata in extract-i18n.js)` — in that case, add the metadata entry there first, then re-run extraction.
|
||||
|
||||
## Step 5: Verify
|
||||
|
||||
Re-run the count loop from Step 3 and confirm zero untranslated strings remain. Report:
|
||||
|
||||
- number of msgids extracted
|
||||
- per-locale count of strings translated
|
||||
- any locales that were already complete
|
||||
|
||||
Optionally, run the koplugin Lua tests if `busted`/`luajit` are installed:
|
||||
|
||||
```bash
|
||||
cd <repo-root>/apps/readest-app
|
||||
pnpm test:lua
|
||||
```
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
name: i18n
|
||||
description: >
|
||||
Extract i18n strings, translate missing translations, or add a new language to readest-app.
|
||||
Use when the user invokes /i18n or asks to extract/translate i18n strings or add a new locale.
|
||||
Runs i18next-scanner to extract keys, then translates any __STRING_NOT_TRANSLATED__
|
||||
placeholders across all locale files.
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
Extract/translate i18n strings or add a new language for readest-app. Run from the app directory (either the main repo or a worktree).
|
||||
|
||||
## Step 0: Determine the mode
|
||||
|
||||
- If the user asks to **add a new language/locale**, go to **Adding a New Language** below.
|
||||
- Otherwise, go to **Extracting & Translating Strings** below.
|
||||
|
||||
## Step 1: Determine the working directory
|
||||
|
||||
If currently in a PR worktree (e.g., `/Users/chrox/dev/readest-pr-*`), use that. Otherwise use the main repo. The app directory is `<repo-root>/apps/readest-app`.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Language
|
||||
|
||||
When the user asks to add a new language (e.g., "add Hungarian", "add hu locale"):
|
||||
|
||||
### Step A1: Register the locale in two places
|
||||
|
||||
1. **`i18n-langs.json`** — Append the locale code to the array. Both `i18next-scanner.config.cjs` and `src/i18n/i18n.ts` import from this file, so they pick up the new entry automatically.
|
||||
2. **`src/services/constants.ts`** — Add an entry to `TRANSLATED_LANGS` with the locale code and native language name (e.g., `hu: 'Magyar'`). If the locale already exists in `TRANSLATOR_LANGS`, remove the duplicate there since it will be inherited via the spread.
|
||||
|
||||
### Step A2: Generate the translation file
|
||||
|
||||
```bash
|
||||
cd <app-dir>
|
||||
pnpm run i18n:extract
|
||||
```
|
||||
|
||||
This creates `public/locales/<code>/translation.json` with all keys set to `__STRING_NOT_TRANSLATED__`.
|
||||
|
||||
### Step A3: Translate all strings
|
||||
|
||||
Follow **Step 4** below to translate every `__STRING_NOT_TRANSLATED__` entry in the new locale file.
|
||||
|
||||
### Step A4: Verify
|
||||
|
||||
Follow **Step 5** below to confirm zero remaining untranslated strings for the new locale.
|
||||
|
||||
---
|
||||
|
||||
## Extracting & Translating Strings
|
||||
|
||||
### Step 2: Extract i18n strings
|
||||
|
||||
```bash
|
||||
cd <app-dir>
|
||||
pnpm run i18n:extract
|
||||
```
|
||||
|
||||
This runs `i18next-scanner` which scans source files for translation keys and adds any new keys to all locale files with `__STRING_NOT_TRANSLATED__` as the placeholder value.
|
||||
|
||||
### Step 3: Find untranslated strings
|
||||
|
||||
```bash
|
||||
grep -r "__STRING_NOT_TRANSLATED__" <app-dir>/public/locales/
|
||||
```
|
||||
|
||||
If no results, report that all strings are already translated and stop.
|
||||
|
||||
### Step 4: Translate missing strings
|
||||
|
||||
For each `__STRING_NOT_TRANSLATED__` found:
|
||||
|
||||
1. Identify the English key (e.g., `"Hide Scrollbar"`)
|
||||
2. Identify the target locale from the file path (e.g., `locales/ja/translation.json` -> Japanese)
|
||||
3. Provide an accurate translation for each locale
|
||||
|
||||
Use `sed -i ''` on macOS to replace in-place. Handle all locales in one batch:
|
||||
|
||||
```bash
|
||||
cd <app-dir>/public/locales
|
||||
sed -i '' 's/"<Key>": "__STRING_NOT_TRANSLATED__"/"<Key>": "<translation>"/' <locale>/translation.json
|
||||
```
|
||||
|
||||
### Locale reference
|
||||
|
||||
The canonical, complete list of supported locales lives in `i18n-langs.json` (codes only) and `TRANSLATED_LANGS` in `src/services/constants.ts` (codes → native display names). Read those for the source of truth — translate every locale that appears in `i18n-langs.json`. Don't carry forward older "out-of-scope" exclusions like `pt-BR` or `uz`; if it's in `i18n-langs.json` it ships, and it needs translation.
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
```bash
|
||||
grep -r "__STRING_NOT_TRANSLATED__" <app-dir>/public/locales/
|
||||
```
|
||||
|
||||
Confirm zero remaining untranslated strings. Report the number of keys translated and locales updated.
|
||||
@@ -2,11 +2,5 @@ PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
|
||||
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_DEV_BASE64="cGtfdGVzdF81MVJmQmdLRTdSWW5pTWsxc0tDV2RUd2hMZzcySzk4eDRWcjlIdDdsRFBONngzcnpZYmhydGtNQnpDdzZKbHFaRVVITVp5eVNjVXhCZXVkcGppWTk0WXNHcDAweFlRRnRRaUU="
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_BASE64="cGtfbGl2ZV81MVFYN3dRRU5ndjJFOUxQRHpZUlE5TlJJeTNjd09EZ1AzSkNFRHRPWlFtdFJWc3Brd053ZE1NNUpIVnVPTmJWcjZ3VGFCMUNZR1pJMmRPVWppTkY0bHJvVjAwalE4TkpkdWk="
|
||||
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
|
||||
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
|
||||
@@ -1,32 +0,0 @@
|
||||
NEXT_PUBLIC_POSTHOG_KEY=YOUR_POSTHOG_KEY
|
||||
NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=https://your-api-base-url.com
|
||||
|
||||
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
|
||||
|
||||
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
|
||||
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
|
||||
|
||||
# r2, s3
|
||||
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
|
||||
|
||||
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
|
||||
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
|
||||
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
|
||||
R2_REGION=YOUR_R2_REGION
|
||||
|
||||
S3_ENDPOINT=PLACE_HOLDER
|
||||
S3_ACCESS_KEY_ID=PLACE_HOLDER
|
||||
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
|
||||
S3_BUCKET_NAME=PLACE_HOLDER
|
||||
S3_REGION=PLACE_HOLDER
|
||||
|
||||
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
|
||||
@@ -1,3 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
|
||||
DBUS_ID=com.bilingify.readest
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
@@ -1,3 +0,0 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
NEXT_PUBLIC_AI_GATEWAY_API_KEY=your_key_here
|
||||
@@ -8,11 +8,6 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.test-sandbox-node/
|
||||
.vitest-attachments/
|
||||
|
||||
# benchmarks — personal local perf history; share via PR/issue copy-paste
|
||||
bench/results.jsonl
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -37,7 +32,7 @@ yarn-error.log*
|
||||
/private_keys
|
||||
|
||||
# plists
|
||||
Entitlements*.plist
|
||||
*.plist
|
||||
|
||||
# local confs
|
||||
tauri.*.conf.json
|
||||
@@ -45,38 +40,12 @@ tauri.*.conf.json
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# open-next
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
#generated
|
||||
src-tauri/gen
|
||||
|
||||
# vendor
|
||||
/public/vendor
|
||||
|
||||
# Auto Generated PWA files
|
||||
/public/sw.js
|
||||
/public/workbox-*.js
|
||||
/public/fallback-*.js
|
||||
/public/swe-worker-*.js
|
||||
|
||||
/dist/
|
||||
|
||||
.context/
|
||||
.claude/settings.local.json
|
||||
.claude/skills
|
||||
|
||||
# Playwright web e2e
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
## Project Overview
|
||||
|
||||
Readest is a cross-platform ebook reader built as a **Next.js 16 + Tauri v2** hybrid app. It's part of a pnpm monorepo at `/apps/readest-app/`. The app runs on web (CloudFlare Workers), desktop (macOS/Windows/Linux via Tauri), and mobile (iOS/Android via Tauri).
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev-web # Web-only dev server (no Rust compilation needed)
|
||||
pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
|
||||
|
||||
# Building
|
||||
pnpm build # Build Next.js for Tauri
|
||||
pnpm build-web # Build Next.js for web deployment
|
||||
|
||||
# Testing (see [docs/testing.md](docs/testing.md) for full details)
|
||||
pnpm test # Unit tests (vitest + jsdom)
|
||||
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
|
||||
pnpm test -- --watch # Watch mode
|
||||
pnpm test:browser # Browser tests (Chromium via Playwright)
|
||||
pnpm tauri:dev:test # Start Tauri app with webdriver
|
||||
pnpm test:tauri # Run Tauri integration tests
|
||||
|
||||
# Linting & Formatting
|
||||
pnpm lint # Biome (linter) + tsgo (type check)
|
||||
pnpm format # Biome formatter (runs from monorepo root)
|
||||
pnpm format:check # Check formatting without writing (Biome)
|
||||
|
||||
# Rust
|
||||
pnpm fmt:check # Check formatting Rust code (src-tauri)
|
||||
pnpm clippy:check # Lint Rust code (src-tauri)
|
||||
```
|
||||
|
||||
### Source Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `src/app/` | Next.js App Router pages and API routes |
|
||||
| `src/components/` | React components (reader, settings, library, assistant, etc.) |
|
||||
| `src/services/` | Business logic: TTS, translators, OPDS, sync, AI, metadata |
|
||||
| `src/store/` | Zustand state stores |
|
||||
| `src/hooks/` | Custom React hooks |
|
||||
| `src/libs/` | Document loaders, payment, storage, sync |
|
||||
| `src/utils/` | Pure utility functions |
|
||||
| `src/types/` | TypeScript type definitions |
|
||||
| `src/context/` | React Context providers (Auth, Env, Sync, etc.) |
|
||||
| `src/workers/` | Web Workers for background tasks |
|
||||
| `src-tauri/` | Rust backend: Tauri plugins, platform-specific code |
|
||||
|
||||
### Path Aliases (tsconfig)
|
||||
|
||||
- `@/*` → `./src/*`
|
||||
- `@/components/ui/*` → `./src/components/primitives/*`
|
||||
|
||||
### Rust Backend (`src-tauri/`)
|
||||
|
||||
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
|
||||
|
||||
## Git Worktrees
|
||||
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
|
||||
|
||||
```bash
|
||||
pnpm worktree:new feat/my-feature # New branch from origin/main
|
||||
pnpm worktree:new 3837 # Checkout PR #3837 with push access to fork
|
||||
```
|
||||
|
||||
## Agent Workspace
|
||||
|
||||
Project-related agent context lives under `.agents/`, which is a symlink to `.claude/`. Treat `.agents/` as the canonical path when looking for or updating local agent material:
|
||||
|
||||
- `.agents/memory/` — persistent project memory and recurring context
|
||||
- `.agents/plans/` — active or archived implementation plans
|
||||
- `.agents/rules/` — project rules for test-first work, TypeScript, verification, and related workflows
|
||||
|
||||
## Project Rules
|
||||
|
||||
Rules are in `.agents/rules/`: test-first, typescript, verification.
|
||||
|
||||
### Implementation Scope
|
||||
|
||||
For every coding task, write the minimum code that solves the requested problem.
|
||||
|
||||
- Do not add features beyond what was asked.
|
||||
- Do not add abstractions for single-use code.
|
||||
- Do not add flexibility or configurability unless requested.
|
||||
- Do not add error handling for impossible scenarios.
|
||||
- If a solution is much longer than necessary, simplify it before finishing.
|
||||
- Before shipping, ask: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### i18n
|
||||
|
||||
See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `stubTranslation` usage in non-React modules, and extraction workflow.
|
||||
|
||||
### Safe Area Insets
|
||||
|
||||
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
|
||||
|
||||
### Design System
|
||||
|
||||
UI/UX rules — surface tiers, action vocabulary, settings primitives (`BoxedList`, `SettingsRow`, `SettingsSwitchRow`, `SettingsSelect`, `NavigationRow`, `Tips`, etc.), boxed-list anatomy, RTL conventions, e-ink overlay, and anti-patterns — live in [DESIGN.md](DESIGN.md). Codify recurring decisions there so they persist for the team and future contributors. Reach for the primitives in `src/components/settings/primitives/` instead of inlining chassis classes.
|
||||
|
||||
### E-ink mode
|
||||
|
||||
Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones:
|
||||
|
||||
- **Surfaces / inputs** — add `eink-bordered`. In eink mode it swaps to `bg-base-100` + 1px `base-content` border. Use it on inputs, custom button backgrounds, ghost-styled cancel buttons, and any container that needs a visible boundary.
|
||||
- **Primary action buttons** — add `btn-primary` (alongside whatever Tailwind classes you use for color themes). The `[data-eink] .btn-primary` rule inverts to `base-content` bg + `base-100` text so the primary CTA stays distinct from secondary actions.
|
||||
- **`.modal-box`** picks up no-shadow + 1px border automatically; dialogs that use it don't need additions.
|
||||
- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference.
|
||||
|
||||
When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -1,972 +0,0 @@
|
||||
## Readest Design Language
|
||||
|
||||
Readest's UI is **Adwaita-aligned**, **e-ink-first**, **cross-platform-aware**. This doc is the
|
||||
reference for that language: principles, vocabulary, anti-patterns. New work should read it
|
||||
before reaching for daisyui defaults; existing work is gradually migrating toward it.
|
||||
|
||||
### Status
|
||||
|
||||
This doc is the **first articulation** of the system, not a retrospective. Many existing
|
||||
components don't fully match it yet (especially older buttons and ad-hoc panels). The goal
|
||||
is that **new code uses these conventions** and **migrations land opportunistically** as
|
||||
features get touched.
|
||||
|
||||
---
|
||||
|
||||
### 1. Identity & lineage
|
||||
|
||||
Readest's visual language descends from **Adwaita / libadwaita** — GNOME's design system —
|
||||
adapted for a cross-platform Tauri + Next.js app that also runs on iOS, Android, web, and
|
||||
e-ink readers.
|
||||
|
||||
What we take from Adwaita:
|
||||
|
||||
- **Content first, chrome recedes.** The reading surface is the product. Settings, toolbars,
|
||||
popups never compete with the page.
|
||||
- **Boldly minimal.** Restraint over density. Whitespace is structural.
|
||||
- **Surface hierarchy** — window → view → card — three explicit elevation tiers, no shadow
|
||||
gymnastics.
|
||||
- **Color discipline.** Brand color is rare and earned. Neutral palette carries the weight.
|
||||
- **Boxed lists are the chassis.** AdwActionRow's prefix · title · suffix anatomy is the
|
||||
canonical settings/list row everywhere.
|
||||
- **Pills, ghosts, flats.** Three-tier button palette: pill/circular ghost in headers, flat
|
||||
secondary over view-bg, accent CTA only when truly primary.
|
||||
- **Banner vs Toast.** AdwBanner = inline, top-of-window, persistent. AdwToast = transient,
|
||||
bottom slide-in.
|
||||
- **Switches over checkboxes** for boolean settings.
|
||||
- **Subtle motion.** Short, ease-out, never bouncy.
|
||||
|
||||
What's Readest-specific:
|
||||
|
||||
- **E-ink as a first-class mode.** Every surface flips to flat 1px contrast borders under
|
||||
`[data-eink='true']`. Adwaita is desktop-GNOME-only; we ship to e-ink readers and the
|
||||
visual language has to survive there.
|
||||
- **Cross-platform reality.** Readest runs on macOS, Windows, Linux, iOS, Android, web. The
|
||||
identity stays Adwaita; platform grace notes (radii, target sizes) follow host
|
||||
conventions where they matter.
|
||||
|
||||
---
|
||||
|
||||
### 2. Principles
|
||||
|
||||
The seven rules. When in doubt, work backward from these.
|
||||
|
||||
#### 2.1 Surfaces continue surfaces
|
||||
|
||||
A control that extends a list/card should match its parent's border + fill. The
|
||||
"+ Import Dictionary" button at `src/components/settings/CustomDictionaries.tsx` reads as
|
||||
detached card siblings of the dictionary list above it because they share
|
||||
`border-base-200 bg-base-100 rounded-lg`.
|
||||
|
||||
> **Bad**: a list of dictionaries in a `bg-base-100` card, followed by a `btn-outline btn-primary`
|
||||
> add button. The button shouts; the list whispers; the eye bounces.
|
||||
>
|
||||
> **Good**: list and add-button share the same surface vocabulary. The eye flows.
|
||||
|
||||
#### 2.2 Color is earned
|
||||
|
||||
Brand `primary` is reserved for **the** primary action of a surface. Most actions don't have
|
||||
a primary action — they have a list of equally-weighted choices, or a single accent.
|
||||
|
||||
- Settings dialog has no primary. Every panel is a list of toggles. **Zero brand color.**
|
||||
- "Import a Book" in onboarding is a primary CTA. **One brand color.**
|
||||
- "Add Web Search" extends a list — it's not the surface's primary action. **Neutral.**
|
||||
|
||||
#### 2.3 Two-step depth
|
||||
|
||||
State changes cycle through **`base-100 → base-200 → base-300`** instead of recoloring.
|
||||
Hover lifts, active deepens, disabled fades opacity. This is theme-safe (works across all
|
||||
11 color themes), e-ink-friendly (depth is preserved as borders, not shades), and
|
||||
calmer than recoloring.
|
||||
|
||||
#### 2.4 Localize the hover signal
|
||||
|
||||
When a button hovers, **one focal element changes**, not the whole button. The icon chip
|
||||
inverts; the label stays steady. The badge intensifies; the row stays neutral. This reads
|
||||
as deliberate, not decorative.
|
||||
|
||||
#### 2.5 Motion is color, not transform
|
||||
|
||||
Default to `transition-colors duration-150`. No `scale`, no `translate`, no `rotate` unless
|
||||
the motion **is** the message (a chevron rotating to indicate expansion is fine; a button
|
||||
that scales on hover is not). Transforms break under `[data-eink='true']` and feel
|
||||
gimmicky under Adwaita's calm rhythm.
|
||||
|
||||
#### 2.6 Eink-first by default
|
||||
|
||||
Every custom-styled bordered surface gets the `eink-bordered` class. Every primary action
|
||||
gets `btn-primary` (which has dedicated eink rules). Don't rely on color or shadow alone
|
||||
for hierarchy — eink screens have neither.
|
||||
|
||||
If you can't toggle Settings → Misc → Eink and still tell which button is the CTA, the
|
||||
hierarchy is broken.
|
||||
|
||||
#### 2.7 Focus is visible but quiet
|
||||
|
||||
Keyboard focus needs a visible ring. `focus-visible:ring-2 focus-visible:ring-base-content/15`
|
||||
is the canonical treatment for custom buttons. Loud `ring-primary` reserved for inputs
|
||||
where the focus state IS the affordance.
|
||||
|
||||
#### 2.8 RTL: always use logical properties (REQUIRED)
|
||||
|
||||
Readest ships with RTL languages enabled. **Never use direction-bound Tailwind
|
||||
utilities** when a logical equivalent exists — the visual edges flip in RTL,
|
||||
the logical ones don't.
|
||||
|
||||
| Don't use | Use instead |
|
||||
| ---------------------------------- | ---------------------------------- |
|
||||
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
|
||||
| `ml-*` / `mr-*` | `ms-*` / `me-*` |
|
||||
| `text-left` / `text-right` | `text-start` / `text-end` |
|
||||
| `border-l` / `border-r` | `border-s` / `border-e` |
|
||||
| `rounded-l-*` / `rounded-r-*` | `rounded-s-*` / `rounded-e-*` |
|
||||
| `left-*` / `right-*` (positioning) | `start-*` / `end-*` |
|
||||
| `justify-start` / `justify-end` | (these ARE direction-aware) — keep |
|
||||
|
||||
The `flex-row` direction is automatically reversed in RTL by the browser, so
|
||||
you usually don't need to do anything for `flex` / `gap`. Only **explicit
|
||||
edges** (padding, margin, borders, radius, absolute positioning) need
|
||||
logical properties.
|
||||
|
||||
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
|
||||
case (rare — usually only icon glyphs that have a fixed orientation) should
|
||||
be flipped to the logical equivalent.
|
||||
|
||||
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
|
||||
|
||||
Every settings panel and every sub-page must open with:
|
||||
|
||||
1. **A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
|
||||
top-level panel this is an `<h2>`; in a sub-page this is the `parentLabel /
|
||||
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
|
||||
word stays anchored visually as the user navigates in/out).
|
||||
2. **A one-line description** — a short sentence under the title explaining what this
|
||||
surface does or how it fits in the user's workflow. Style: `text-sm
|
||||
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
|
||||
the breadcrumb already says everything (rare — when in doubt, write one).
|
||||
|
||||
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
|
||||
both). The same vertical opening across every surface makes the system feel cohesive
|
||||
and gives users a predictable place to learn what a screen does.
|
||||
|
||||
**Canonical components.** The `<SubPageHeader>` primitive in
|
||||
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
|
||||
prop that renders the description in the canonical style — sub-pages should pass it
|
||||
there rather than rolling their own `<p>` below the header. Top-level panels currently
|
||||
inline the title + description; if a third or fourth panel needs the same pattern,
|
||||
extract a `<PanelHeader>` primitive following the same shape.
|
||||
|
||||
**Examples.**
|
||||
|
||||
```tsx
|
||||
// Sub-page (Integrations → OPDS Catalogs)
|
||||
<SubPageHeader
|
||||
parentLabel={_('Integrations')}
|
||||
currentLabel={_('OPDS Catalogs')}
|
||||
description={_('Browse and download books from online catalogs')}
|
||||
onBack={() => setSubPage(null)}
|
||||
/>
|
||||
|
||||
// Top-level panel (Integrations panel root)
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
|
||||
<p className='text-base-content/70 text-sm leading-relaxed'>
|
||||
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Surface hierarchy
|
||||
|
||||
Three named tiers, mapped onto daisyui tokens. Use these terms in conversation and code
|
||||
comments even though the classes are still daisyui-native.
|
||||
|
||||
| Tier | Token | Role | Example |
|
||||
| ---------- | ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `<Dialog>` body |
|
||||
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
|
||||
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
|
||||
|
||||
Border treatment:
|
||||
|
||||
- **Window** has no border (it IS the boundary).
|
||||
- **View** uses no border or `border-base-200/60` for very soft delineation.
|
||||
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
|
||||
`border-base-content`.
|
||||
|
||||
Corner radius:
|
||||
|
||||
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
|
||||
close enough and matches Tailwind's scale.
|
||||
- **Modal / Sheet**: `modal-box` default (~1rem / 16px) — bigger surfaces get bigger radii.
|
||||
- **Pills / Chips**: `rounded-full`.
|
||||
- **Inputs / small buttons**: `rounded-md` (6px) or `rounded-lg` (8px).
|
||||
|
||||
#### Surface continuity rule
|
||||
|
||||
When a control extends a card (an "add row" affordance, a footer button bar attached to a
|
||||
list), it inherits the card's surface treatment: same `bg-base-100`, same
|
||||
`border-base-200`, same `rounded-lg`. It is the card grown by one row.
|
||||
|
||||
---
|
||||
|
||||
### 4. Action vocabulary
|
||||
|
||||
Six archetypes. Pick by **role**, not by **appearance**.
|
||||
|
||||
#### 4.1 Accent CTA
|
||||
|
||||
The primary, accent-colored button. **One per surface, max.** Submit on a form, "Open
|
||||
Book", "Sign In".
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-primary';
|
||||
```
|
||||
|
||||
Eink: `btn-primary` has dedicated rules (inverts to base-content bg + base-100 text) so it
|
||||
stays distinct from secondary actions on monochrome screens.
|
||||
|
||||
#### 4.2 Suggested
|
||||
|
||||
A non-accent-but-emphasized action. Used when there are multiple equally-weighted actions
|
||||
and one is the recommended path. Adwaita's "suggested-action" CSS class.
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-neutral';
|
||||
```
|
||||
|
||||
Rare. Most surfaces don't need this tier.
|
||||
|
||||
#### 4.3 Flat
|
||||
|
||||
The default secondary button. Sits on a view or card surface, no border, hover lifts to
|
||||
`base-200`. The bulk of buttons should be flat.
|
||||
|
||||
```tsx
|
||||
className="btn btn-ghost"
|
||||
// or for a custom surface treatment:
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-base-200 transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
|
||||
)}
|
||||
```
|
||||
|
||||
#### 4.4 Pill / Circular ghost
|
||||
|
||||
Compact icon-only buttons in header bars and toolbars. Always `rounded-full`,
|
||||
`btn-circle` or hand-rolled circular ghost.
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-ghost btn-circle h-8 min-h-8 w-8 p-0';
|
||||
```
|
||||
|
||||
The window controls in `SettingsDialog.tsx` (search, menu, close) use this archetype.
|
||||
|
||||
#### 4.5 Destructive
|
||||
|
||||
Delete, remove, irreversible. Adwaita uses `destructive-action`. Readest uses red
|
||||
sparingly — usually only the icon, not the whole button.
|
||||
|
||||
```tsx
|
||||
// Icon-only delete X in delete mode:
|
||||
className = 'btn btn-ghost btn-sm shrink-0 px-1';
|
||||
// with <IoMdCloseCircleOutline className="text-error h-4 w-4" />
|
||||
```
|
||||
|
||||
For destructive **dialogs** (confirmation modals), the confirm button can be `btn-error`,
|
||||
but only in the modal — never on the main surface.
|
||||
|
||||
#### 4.6 ListExtension
|
||||
|
||||
A Readest-named archetype for "add another row to the list above" affordances. The two
|
||||
buttons at the bottom of `CustomDictionaries.tsx` are the canonical example.
|
||||
|
||||
Anatomy:
|
||||
|
||||
- Surface matches the parent card (`border border-base-200 bg-base-100 rounded-lg`)
|
||||
- Height ~h-11
|
||||
- Centered: small icon chip + label
|
||||
- Icon chip: `bg-base-200 text-base-content/60 rounded-full h-5 w-5`
|
||||
- Hover: border deepens to `base-300`, bg lightens to `bg-base-200/60`, icon chip inverts
|
||||
to `bg-base-content text-base-100`
|
||||
- `eink-bordered` on the button itself
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAdd}
|
||||
className={clsx(
|
||||
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
|
||||
'border-base-200 bg-base-100 rounded-lg border px-4',
|
||||
'text-base-content text-sm font-medium',
|
||||
'transition-colors duration-150',
|
||||
'hover:border-base-300 hover:bg-base-200/60',
|
||||
'active:bg-base-200/80',
|
||||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
'bg-base-200 text-base-content/60',
|
||||
'transition-colors duration-150',
|
||||
'group-hover:bg-base-content group-hover:text-base-100',
|
||||
)}
|
||||
>
|
||||
<MdAdd className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<span className='line-clamp-1'>{label}</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Use this for: "Import Dictionary", "Add Web Search", "Add Custom Theme", any "+ add new
|
||||
to this list" pattern. **Do not** use `btn-outline btn-primary` for these.
|
||||
|
||||
---
|
||||
|
||||
### 5. Boxed list anatomy
|
||||
|
||||
The settings UI is built on boxed lists. One pattern, used everywhere.
|
||||
|
||||
#### Container
|
||||
|
||||
Use the `<BoxedList>` primitive at `src/components/settings/primitives/BoxedList.tsx`
|
||||
rather than inlining the chassis classes:
|
||||
|
||||
```tsx
|
||||
<BoxedList title={_('Reading Sync')} data-setting-id='settings.section.id'>
|
||||
{/* rows */}
|
||||
</BoxedList>
|
||||
```
|
||||
|
||||
The primitive renders:
|
||||
|
||||
```tsx
|
||||
<div className='card eink-bordered border-base-200 bg-base-100 border'>
|
||||
<div className='divide-base-200 divide-y'>{children}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `card` for the radius
|
||||
- `border border-base-200` for the boundary (eink upgrades this automatically)
|
||||
- `eink-bordered` for the e-ink-mode contrast border
|
||||
- `divide-base-200 divide-y` for inter-row separators
|
||||
|
||||
> **No `overflow-hidden` on the card.** Children may host popovers (color
|
||||
> pickers, dropdowns, tooltips) that need to escape the card bounds. The
|
||||
> `divide-y` rules sit between rows and don't touch the card's rounded
|
||||
> corners, so omitting overflow-clip is visually safe AND keeps embedded
|
||||
> popovers from getting clipped.
|
||||
|
||||
#### Row anatomy
|
||||
|
||||
Three slots, in order, always:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [prefix] Title text [suffix slots] │
|
||||
│ [ ] Subtitle text (optional) [ ][ ]│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Slot | Contents |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| **Prefix** | Drag handle, leading icon, avatar, status dot, or empty. |
|
||||
| **Title** | Primary label. `font-medium`. Truncates with `truncate`. |
|
||||
| **Subtitle** | Optional secondary line. `text-sm text-base-content/70`. Used for warnings, descriptions, status. |
|
||||
| **Suffix** | Badge, switch, button, chevron, value, or any combination. End-aligned. |
|
||||
|
||||
Canonical example: `SortableRow` in `src/components/settings/CustomDictionaries.tsx`. The
|
||||
drag handle is the prefix, the dict name is the title, the warning reason is the
|
||||
subtitle, and the badge + toggle + edit/delete buttons stack as suffixes.
|
||||
|
||||
#### Row variants
|
||||
|
||||
- **ActionRow** — title + suffix is a single button or chevron. Tap anywhere navigates.
|
||||
- **SwitchRow** — title + suffix is a toggle. Tap anywhere toggles.
|
||||
- **ComboRow** — title + suffix is a dropdown/select.
|
||||
- **ExpanderRow** — chevron suffix; tap expands to reveal nested rows.
|
||||
|
||||
These names come from libadwaita and apply 1:1 to Readest's lists. Use the names in code
|
||||
comments and PR descriptions.
|
||||
|
||||
#### Spacing
|
||||
|
||||
- Row vertical padding: `py-2` (8px) for compact lists, `py-3` (12px) for breathing room.
|
||||
- Row horizontal padding: `px-3` (12px) or `px-4` (16px). Stay consistent within a list.
|
||||
- Slot gap: `gap-2` (8px) between prefix/title/suffix elements.
|
||||
|
||||
#### Disabled rows
|
||||
|
||||
Disabled rows fade the title to `text-base-content/60` and disable the suffix control. The
|
||||
row itself stays at full opacity — only the **content** dims, not the row.
|
||||
|
||||
#### Toggle size
|
||||
|
||||
| Daisyui class | Use case |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggle` (default, h-5 / ~20px) | **Settings panel boxed-list rows** — `<SettingsSwitchRow>` uses this. Visible weight matches the 56px `min-h-14` row. |
|
||||
| `toggle-sm` (h-4 / ~16px) | Inline secondary switches in tighter contexts — e.g. dictionary list rows in `CustomDictionaries`. |
|
||||
| `toggle-xs` (h-3 / ~12px) | Compact metadata toggles inside cards — e.g. OPDS catalog "Auto-download". |
|
||||
|
||||
The `<SettingsSwitchRow>` primitive bakes in the default `toggle`. **Don't override
|
||||
to `toggle-sm` inside boxed-list rows** — it looks orphaned in the row's vertical
|
||||
breathing room. Use the smaller sizes only when the row itself is shorter than 56px.
|
||||
|
||||
#### Typography inherits from `.settings-content`
|
||||
|
||||
The Settings dialog (and any settings-style sheet/popup) wraps its content
|
||||
in `.settings-content`, which is defined in `src/styles/globals.css` as:
|
||||
|
||||
```css
|
||||
.dropdown-content,
|
||||
.settings-content {
|
||||
font-size: 14px; /* desktop */
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dropdown-content,
|
||||
.settings-content {
|
||||
font-size: 16px; /* mobile bump — high-DPI phones need bigger body text */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Don't hardcode `text-sm` on row labels, NavigationRow titles, or panel
|
||||
descriptions** — that locks the text to 14px on every viewport and kills
|
||||
the mobile bump. Instead:
|
||||
|
||||
- **Primary labels** (SettingsRow label, NavigationRow title, SubPageHeader
|
||||
description, ad-hoc row labels in panels and integration forms): no
|
||||
font-size class — inherits 14/16 from the wrapper. Use `<SettingLabel>`
|
||||
rather than inlining a `<span>`; it adds `font-medium` for cased scripts
|
||||
and drops the weight for caseless scripts (CJK / Arabic / Hebrew / Indic
|
||||
/ Thai / Tibetan), since those bold poorly at body size and `font-medium`
|
||||
on Han / Hangul / Devanagari renders as uneven stroke-thickening across
|
||||
system fonts.
|
||||
- **Secondary text** (SettingsRow description, NavigationRow status, Tips
|
||||
body, BoxedList description): use `text-[0.85em]` so it stays
|
||||
proportional (≈12px desktop, ≈13.6px mobile).
|
||||
- **Form controls** (`<input>`, `<select>`): browsers don't inherit
|
||||
font-size onto form elements, so add the `settings-content` class
|
||||
_directly on the element_ to re-apply the 14/16 cascade. The legacy
|
||||
NumberInput already does this — match its pattern.
|
||||
- **Section headers** (`BoxedList` uppercase title): use `text-[0.85em]
|
||||
font-semibold uppercase tracking-wider`. The em-relative size keeps it
|
||||
proportional with the `.settings-content` cascade. **Caseless-script
|
||||
exception:** when `isCaselessUILang()` is true, bump to `text-[1em]`.
|
||||
The `uppercase` rule is a no-op in scripts without case (CJK, Arabic,
|
||||
Hebrew, Devanagari/Bengali/Tamil/Sinhala, Thai, Tibetan), so the size
|
||||
has to carry the emphasis those scripts can't pick up from casing. The
|
||||
helper lives in `src/utils/misc.ts`; the underlying `isCaselessLang`
|
||||
predicate lists every covered language code in `src/utils/lang.ts`.
|
||||
|
||||
Why this matters: Tailwind's `text-xs` / `text-sm` are rem-based — they
|
||||
ignore the parent's `font-size` because rem is rooted at the document.
|
||||
The `.settings-content` cascade is in `px`, so any child that picks a
|
||||
Tailwind size literally tunes itself to the desktop default and never
|
||||
grows on mobile. iOS and Android have small physical screens but high
|
||||
DPI, so the mobile bump is what makes the text legible at typical reading
|
||||
distance.
|
||||
|
||||
#### Uniform row height
|
||||
|
||||
Settings rows in a boxed list MUST all be the same visual height. Use
|
||||
`min-h-14 items-center` (56px) on each row container — toggle, select, and
|
||||
input rows then center their controls vertically inside identical boxes.
|
||||
**Don't use `py-3`** — content-driven padding produces uneven heights
|
||||
because toggles, selects (`h-9`), and inputs (`h-9`) have different
|
||||
intrinsic sizes.
|
||||
|
||||
```tsx
|
||||
// ✓ Right — no text-sm; label inherits .settings-content (14/16)
|
||||
<label className='flex min-h-14 items-center justify-between px-4'>
|
||||
<span className='font-medium'>{_('Sync Enabled')}</span>
|
||||
<input type='checkbox' className='toggle' ... />
|
||||
</label>
|
||||
|
||||
// ✗ Wrong — toggle row will be 48px, select rows 60px
|
||||
<label className='flex items-center justify-between px-4 py-3'>...</label>
|
||||
|
||||
// ✗ Wrong — text-sm hardcodes 14px even on mobile (kills the bump)
|
||||
<span className='text-sm font-medium'>{_('Sync Enabled')}</span>
|
||||
```
|
||||
|
||||
#### Controls inside a boxed list have no chrome
|
||||
|
||||
When a control sits inside a bordered card, it shouldn't carry its own
|
||||
border or fill. The card supplies the visual boundary; the control just
|
||||
sits on the row.
|
||||
|
||||
- **Selects:** drop `select-bordered` and `eink-bordered`. Add
|
||||
`!bg-transparent !bg-none !appearance-none` to suppress daisyui's
|
||||
background chevron and native arrow. Render a real `<MdArrowDropDown>`
|
||||
icon at the cell's trailing edge for the affordance — see "End-aligned
|
||||
values" below.
|
||||
- **Inputs:** drop `input-bordered` and `eink-bordered`. Add `!bg-transparent`
|
||||
with `hover:!bg-base-200/60 focus:!bg-base-200/60` so the field still
|
||||
signals interactability. Use `text-end` and `!pe-0` so the value sits
|
||||
flush against the row's trailing edge.
|
||||
- **Toggles:** untouched — they're already chromeless.
|
||||
|
||||
This is the iOS Settings / Adwaita PreferencesGroup convention: list
|
||||
chrome belongs to the container, not its children.
|
||||
|
||||
#### End-aligned values + chevron alignment
|
||||
|
||||
The selected value of a select/input MUST end-align (`text-end`). The
|
||||
**visible right edge** of every row's value (toggle, chevron icon, input
|
||||
text) MUST land at the same X — the row's trailing padding.
|
||||
|
||||
The trap: daisyui's select renders its chevron via background-image at
|
||||
`calc(100% - 1rem) center`, which floats the glyph 16px _inside_ the
|
||||
select's right edge. So if the toggle in row 1 ends at the row's `pe-4`
|
||||
edge, the chevron in row 2 ends 16px before that — visibly misaligned.
|
||||
|
||||
**Fix:** suppress daisyui's bg-image chevron and render an explicit icon at
|
||||
the cell's trailing edge. The select's own daisyui focus chrome (outline +
|
||||
box-shadow + ring) is suppressed; **no focus ring** on controls inside the
|
||||
boxed list — focus state is signaled by a subtle wrapper bg-shift instead
|
||||
(hover and focus-within both lift to `bg-base-200/60`). Rings would compete
|
||||
with the card's own border and double-stack with adjacent rows.
|
||||
|
||||
```tsx
|
||||
<div className='hover:bg-base-200/60 focus-within:bg-base-200/60 flex max-w-[60%] items-center rounded-md'>
|
||||
<select className='select h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'>
|
||||
{/* options */}
|
||||
</select>
|
||||
<MdArrowDropDown
|
||||
aria-hidden='true'
|
||||
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
> **Why so many `!` overrides?** daisyui's `.select` and `.input` apply
|
||||
> `border-width: 1px` + `border-color` (transparent at rest, `var(--bc)` on
|
||||
> focus), plus `outline`, `box-shadow`, and `ring` chrome on focus. To make
|
||||
> the control truly chromeless inside a boxed list, you need to kill all
|
||||
> four properties. Missing any of them — especially `border-0` — leaves a
|
||||
> visible focus border leaking through.
|
||||
|
||||
The `<MdArrowDropDown>` icon's trailing edge now lives at the same X as the
|
||||
toggle's trailing edge in adjacent rows, because both are flush with the
|
||||
row's `pe-4` padding.
|
||||
|
||||
For inputs, no wrapper is needed — the input is one element, so put the
|
||||
hover/focus bg directly on it. Suppress daisyui's own focus chrome the
|
||||
same way:
|
||||
|
||||
```tsx
|
||||
<input className='input hover:!bg-base-200/60 focus:!bg-base-200/60 h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-0 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0' />
|
||||
```
|
||||
|
||||
> **Why no ring here when §2.7 says "focus needs a visible ring"?** §2.7 is
|
||||
> for standalone custom buttons (Submit, Cancel, ListExtension, etc.). In a
|
||||
> boxed list, the row already provides strong visual containment via the
|
||||
> card border + dividers, and stacking a per-control ring inside that
|
||||
> creates double chrome. The bg-shift IS the focus indicator — keyboard
|
||||
> users still get clear feedback; the surface stays calm.
|
||||
|
||||
---
|
||||
|
||||
### 6. Header bars, dialogs, popups, sheets
|
||||
|
||||
#### Header bar
|
||||
|
||||
The dialog/page header. Adwaita's AdwHeaderBar.
|
||||
|
||||
- **48–56px tall** (`h-12` to `h-14`).
|
||||
- **Center-aligned title** in `font-semibold text-base`.
|
||||
- **Leading slot**: back chevron (mobile) or empty (desktop).
|
||||
- **Trailing slot**: window controls — search (pill ghost), menu (pill ghost),
|
||||
close (pill ghost circle with `bg-base-300/65`).
|
||||
- No bottom border; rely on tab/divider that follows.
|
||||
|
||||
`SettingsDialog.tsx`'s mobile header is the canonical example. The desktop header is
|
||||
slightly different — tabs sit in the same row as window controls, no center title — but
|
||||
it's the same archetype adapted for screen real estate.
|
||||
|
||||
#### Dialog (modal)
|
||||
|
||||
```tsx
|
||||
<Dialog
|
||||
isOpen={...}
|
||||
onClose={...}
|
||||
boxClassName="sm:min-w-[520px] overflow-hidden"
|
||||
header={<HeaderBar />}
|
||||
>
|
||||
{/* content */}
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- `modal-box` provides the radius, max-width, and shadow (auto-removed in eink).
|
||||
- Width ~520px on desktop, full-width on mobile.
|
||||
- Bottom sheets on mobile via `snapHeight` prop.
|
||||
- Backdrop: `sm:!bg-black/50` (or `/20` when nested over a darker surface).
|
||||
|
||||
#### Popup (popover)
|
||||
|
||||
For dictionary lookups, annotation editors, and other anchored overlays. Uses the
|
||||
`Popup` component with a triangle pointer.
|
||||
|
||||
- **Width**: clamp to fit content; ~320–420px typical.
|
||||
- **Surface**: `bg-base-100`, `rounded-lg`, soft shadow (eink removes shadow).
|
||||
- **Triangle**: pointer toward the anchor; eink has special triangle classes.
|
||||
- **Padding**: `p-3` to `p-4` for content.
|
||||
|
||||
#### Sheet (mobile bottom)
|
||||
|
||||
Reserved for mobile contextual menus and full-screen secondary panels. Uses the dialog's
|
||||
`snapHeight` prop. Adwaita doesn't have a native sheet but Readest's mobile pattern is
|
||||
the closest analog.
|
||||
|
||||
- Always full-width.
|
||||
- Top corners rounded; bottom corners flat (it's anchored to the bottom).
|
||||
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
|
||||
swipe-to-dismiss.
|
||||
|
||||
---
|
||||
|
||||
### 7. Motion + a11y
|
||||
|
||||
#### Motion
|
||||
|
||||
- Default duration: **150ms** for color transitions.
|
||||
- Default easing: browser default (`ease`) or `ease-out`. Never `ease-in`.
|
||||
- Longer transitions (300ms+) only for layout changes (sheet snap, panel slide).
|
||||
- **Never** use `transform` for hover unless the transform IS the message
|
||||
(chevron rotation, drag-handle drag visualization). E-ink doesn't render mid-transitions
|
||||
cleanly and Adwaita's identity is calm.
|
||||
|
||||
```tsx
|
||||
// Good — hover:bg-base-200 with transition-colors
|
||||
className = 'transition-colors duration-150 hover:bg-base-200';
|
||||
|
||||
// Bad — scale on hover
|
||||
className = 'transition-transform hover:scale-105';
|
||||
```
|
||||
|
||||
Existing exceptions: `.window-button` in globals.css uses `hover:scale-105`. That's
|
||||
legacy; new code shouldn't follow it.
|
||||
|
||||
#### Reduced motion
|
||||
|
||||
Reduced-motion preference is honored via the `no-transitions` class
|
||||
(`globals.css:624`). Layout-changing transitions should respect
|
||||
`prefers-reduced-motion: reduce` either via this class or `motion-safe:` Tailwind
|
||||
prefixes.
|
||||
|
||||
#### Focus
|
||||
|
||||
- Every focusable element must have a visible focus indicator.
|
||||
- Custom buttons:
|
||||
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15`.
|
||||
- Inputs: rely on daisyui's input focus ring; inputs with custom styling use
|
||||
`focus:ring-2 focus:ring-primary/40`.
|
||||
- Don't use `outline-none` without `focus-visible:` replacement.
|
||||
|
||||
#### Hit targets
|
||||
|
||||
- **Minimum**: 32px (the size of `btn-sm`).
|
||||
- **Recommended**: 40px (`btn`) on touch surfaces.
|
||||
- **Mobile**: 44px+ for taps that aren't fail-safe (delete, navigate-away).
|
||||
- The `touch-target` class in globals.css extends a small visual control's hit area to
|
||||
44px without changing its rendered size — use it on icon-sized buttons in mobile UIs.
|
||||
|
||||
#### Color contrast
|
||||
|
||||
- Body text on background: WCAG AA (4.5:1) minimum.
|
||||
- Large text: WCAG AA Large (3:1) minimum.
|
||||
- Interactive text on hover state: still passes contrast on the new background.
|
||||
- Theme palette is generated from `(bg, fg, primary)`; the tinycolor pipeline keeps
|
||||
contrast within range, but custom themes can break this — Settings → Color flags
|
||||
low-contrast custom themes.
|
||||
|
||||
#### Keyboard
|
||||
|
||||
- Tab order matches visual order. If you use `flex-row-reverse` for visual layout,
|
||||
consider `tabIndex` to fix order.
|
||||
- Modal focus trap: `<Dialog>` handles this.
|
||||
- Esc to dismiss: `<Dialog>` and `<Popup>` handle this.
|
||||
- Arrow keys for grouped controls (radio-like tab strips, sortable lists). dnd-kit's
|
||||
`KeyboardSensor` is wired for sortable lists.
|
||||
|
||||
---
|
||||
|
||||
### 8. E-ink overlay (cross-cutting)
|
||||
|
||||
E-ink mode is toggled by `[data-eink='true']` on the document. It applies a global
|
||||
override layer in `src/styles/globals.css:484-622` that:
|
||||
|
||||
- Removes all `box-shadow`.
|
||||
- Forces `text-base-content`, `text-blue-*`, `text-red-*`, `text-neutral-content` to a
|
||||
single foreground color.
|
||||
- Inverts `btn-primary` and `btn-outline` to base-content bg + base-100 text.
|
||||
- Adds 1px contrast borders to `.eink-bordered`, `.modal-box`, `.menu-container`,
|
||||
`.popup-container`, `.alert`, `.opds-navigation .card`, `.booknote-item`,
|
||||
`.bookitem-main`.
|
||||
|
||||
What this means for new components:
|
||||
|
||||
| Surface type | Required class | Why |
|
||||
| ------------------------------------ | ----------------------- | ------------------------------------------------------------- |
|
||||
| Custom bordered button or input | `eink-bordered` | Gets the 1px contrast border in eink |
|
||||
| Primary CTA | `btn-primary` | Picks up the inverted treatment |
|
||||
| Cancel / secondary action | `btn-ghost` (no border) | Reads as "outlined" only after pairing with the CTA |
|
||||
| Card / panel using `border-base-200` | `eink-bordered` | Otherwise the soft border vanishes in eink |
|
||||
| Modal / Popup | (auto) | `modal-box` and `.popup-container` are handled in globals.css |
|
||||
|
||||
Verification checklist before shipping a new UI:
|
||||
|
||||
- [ ] Toggle Settings → Misc → Eink mode and re-test every screen.
|
||||
- [ ] Every container that has a soft border (`border-base-200`) still has visible
|
||||
delineation.
|
||||
- [ ] Every CTA is distinguishable from its neighbors (cancel, secondary).
|
||||
- [ ] No hover transforms make the UI feel jumpy.
|
||||
- [ ] Text is fully opaque (no `text-base-content/60` content; eink can't render the
|
||||
reduced opacity well).
|
||||
|
||||
#### What's NOT compatible with e-ink
|
||||
|
||||
- Drop shadows for hierarchy (use borders).
|
||||
- Color-only state changes (use border weight or fill swap).
|
||||
- Hover scale / translate (they look broken on slow refresh).
|
||||
- Animations longer than ~200ms (visible refresh artifacts).
|
||||
|
||||
---
|
||||
|
||||
### 9. Cross-platform grace notes
|
||||
|
||||
Readest ships on **macOS, Windows, Linux, iOS, Android, web**. Adwaita is desktop-GNOME-
|
||||
native; we adapt where the host OS has strong conventions, but never at the cost of
|
||||
identity.
|
||||
|
||||
#### iOS
|
||||
|
||||
- Slightly larger corner radii feel native (`rounded-xl` on dialogs, `rounded-lg` on
|
||||
cards).
|
||||
- Safe area insets are mandatory for top + bottom anchored elements (see
|
||||
`docs/safe-area-insets.md`).
|
||||
- Avoid Material Design ripple effects.
|
||||
- Sheet-style modals (bottom-anchored) match iOS conventions and are preferred over
|
||||
centered dialogs on phone-sized screens.
|
||||
|
||||
#### Android
|
||||
|
||||
- Material 3 conventions that conflict with Adwaita (FABs, elevation shadows, ripple
|
||||
inks): **don't** copy them. Readest's identity is Adwaita; the user is reading on
|
||||
Android, not in Android.
|
||||
- Touch targets bumped to 48px for primary actions (Material's recommended target).
|
||||
- Back-gesture-aware UIs: ensure swipe-from-edge doesn't conflict with horizontal swipe
|
||||
controls.
|
||||
|
||||
#### Linux
|
||||
|
||||
- Native Adwaita territory. Readest can match host theme for window chrome (Tauri
|
||||
decorations) but should keep its own internal palette for the reading surface — book
|
||||
themes (sepia, gruvbox, etc.) are user choices, not OS choices.
|
||||
|
||||
#### macOS / Windows
|
||||
|
||||
- Window controls (close/minimize/maximize) are platform-native via Tauri.
|
||||
- Title bar height matches platform convention; internal layout follows Readest's
|
||||
Adwaita palette.
|
||||
|
||||
#### Web
|
||||
|
||||
- No safe-area insets needed.
|
||||
- Keyboard shortcuts are doubled with command-palette discoverability (Cmd/Ctrl+K).
|
||||
- Browser-native focus rings: respected, augmented with `focus-visible:ring-*`.
|
||||
|
||||
#### E-ink readers (Android-based, custom firmware)
|
||||
|
||||
- Detected via the eink mode toggle (Settings → Misc).
|
||||
- All rules in §8 apply.
|
||||
- This is a **first-class** target, not a fallback.
|
||||
|
||||
---
|
||||
|
||||
### 10. Anti-patterns
|
||||
|
||||
Things that LOOK fine in isolation but break the system. Each one has a real source diff
|
||||
or commit reference.
|
||||
|
||||
#### 10.1 Loud outlined CTAs for non-primary actions
|
||||
|
||||
```tsx
|
||||
// Anti-pattern (was in CustomDictionaries.tsx, fixed Nov 2026):
|
||||
<button className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'>
|
||||
<MdAdd className='h-5 w-5' />
|
||||
Import Dictionary
|
||||
</button>
|
||||
|
||||
// Correct: ListExtension archetype (see §4.6)
|
||||
```
|
||||
|
||||
Why it broke: the buttons read as primary CTAs but are list extensions. They competed
|
||||
with the active settings tab indicator and pulled the eye from the list itself.
|
||||
|
||||
#### 10.2 Recoloring the whole button on hover
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<button className="text-base-content/70 hover:text-base-content hover:bg-primary/10">
|
||||
|
||||
// Correct: keep the label color steady, hover via bg shift on the surface
|
||||
<button className="text-base-content hover:bg-base-200 transition-colors">
|
||||
```
|
||||
|
||||
Why: principle 2.4 (localize the hover signal). Whole-button color shifts feel decorative.
|
||||
|
||||
#### 10.3 Transform-based hover
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<button className="hover:scale-105 transition-transform">
|
||||
|
||||
// Correct: color/border-based hover
|
||||
<button className="hover:bg-base-200 hover:border-base-300 transition-colors">
|
||||
```
|
||||
|
||||
Why: breaks under e-ink (§2.5), feels jumpy under Adwaita's calm rhythm.
|
||||
|
||||
#### 10.4 Soft borders without `eink-bordered`
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<div className="border border-base-200 bg-base-100 rounded-lg p-4">
|
||||
...
|
||||
</div>
|
||||
|
||||
// Correct:
|
||||
<div className="eink-bordered border border-base-200 bg-base-100 rounded-lg p-4">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
Why: in e-ink mode, `base-200` borders disappear into the background. `eink-bordered`
|
||||
flips the border to `base-content` so the boundary stays visible.
|
||||
|
||||
Exception: containers that **don't** need a visible boundary in eink (e.g., a
|
||||
`bg-base-100` surface that's already against `bg-base-200`) can skip `eink-bordered`.
|
||||
The class is opt-in for "this surface needs a border to read correctly".
|
||||
|
||||
#### 10.5 Reduced-opacity text in e-ink
|
||||
|
||||
```tsx
|
||||
// Anti-pattern (in eink):
|
||||
<span className="text-base-content/50">Optional metadata</span>
|
||||
|
||||
// Correct (still readable in eink):
|
||||
<span className="text-base-content text-xs">Optional metadata</span>
|
||||
// Or use semantic muting that the eink overlay handles:
|
||||
<span className="text-neutral-content">Optional metadata</span>
|
||||
```
|
||||
|
||||
Why: e-ink's reduced color depth turns `/50` opacity into illegible mush. Use size or
|
||||
weight for hierarchy on muted secondary text.
|
||||
|
||||
#### 10.6 Daisyui `btn` defaults without intent
|
||||
|
||||
```tsx
|
||||
// Anti-pattern: just reaching for `btn` with no role:
|
||||
<button className="btn">Click me</button>
|
||||
|
||||
// Correct: pick an archetype from §4.
|
||||
<button className="btn btn-ghost">Cancel</button> // Flat
|
||||
<button className="btn btn-primary">Save</button> // Accent CTA
|
||||
```
|
||||
|
||||
Why: daisyui's `btn` default isn't tuned for any specific role. Pick from the action
|
||||
vocabulary so the button signals its weight in the surface hierarchy.
|
||||
|
||||
#### 10.7 Ad-hoc surface tokens
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<div className="bg-white border-gray-200">
|
||||
|
||||
// Correct:
|
||||
<div className="bg-base-100 border-base-200">
|
||||
```
|
||||
|
||||
Why: hard-coded colors don't theme. Readest has 11 themes plus user-defined custom themes.
|
||||
Always use the daisyui semantic tokens.
|
||||
|
||||
#### 10.8 Mixing `btn` sizes within a surface
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<header>
|
||||
<button className="btn btn-sm">Search</button>
|
||||
<button className="btn btn-md">Settings</button>
|
||||
<button className="btn btn-xs">Close</button>
|
||||
</header>
|
||||
|
||||
// Correct: one size per surface
|
||||
<header>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Search</button>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Settings</button>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Close</button>
|
||||
</header>
|
||||
```
|
||||
|
||||
Why: visual rhythm. Mixed sizes feel like the surface is unfinished.
|
||||
|
||||
---
|
||||
|
||||
### 11. Quick reference
|
||||
|
||||
When designing a new surface, walk this checklist:
|
||||
|
||||
1. **What's the surface tier?** Window / View / Card. (§3)
|
||||
2. **What's the corner radius?** Match the tier. (§3)
|
||||
3. **Is there a primary action?** If yes, ONE accent CTA. If no, all flats. (§4.1, §4.3)
|
||||
4. **Are there list extensions?** Use the ListExtension archetype, not `btn-outline btn-primary`. (§4.6)
|
||||
5. **Is it a list?** Use the BoxedList chassis with ActionRow / SwitchRow / ComboRow / ExpanderRow rows. (§5)
|
||||
6. **Does it need `eink-bordered`?** If it has a soft border that must stay visible in
|
||||
eink mode, yes. (§8)
|
||||
7. **Is the hover signal localized?** One focal element changes, not the whole control. (§2.4)
|
||||
8. **Is motion color-only?** No transforms unless the transform IS the message. (§2.5)
|
||||
9. **Is focus visible?** `focus-visible:ring-2 focus-visible:ring-base-content/15` on
|
||||
custom buttons. (§7)
|
||||
10. **Will it work on the smallest theme + e-ink?** Toggle Sepia + Eink, retest.
|
||||
|
||||
---
|
||||
|
||||
### 12. Glossary
|
||||
|
||||
- **Adwaita / libadwaita**: GNOME's design system and widget toolkit. Source of Readest's
|
||||
visual lineage.
|
||||
- **AdwActionRow / AdwSwitchRow / AdwComboRow / AdwExpanderRow**: libadwaita's row
|
||||
primitives. Readest mirrors these conceptually with custom React components.
|
||||
- **AdwBoxedList**: libadwaita's named container for grouped action rows.
|
||||
- **AdwBanner**: top-of-window inline alert (persistent).
|
||||
- **AdwToast**: bottom slide-in transient alert.
|
||||
- **Window / View / Card**: surface tiers (§3).
|
||||
- **ListExtension**: Readest-named archetype for "+ add new row" buttons (§4.6).
|
||||
- **eink-bordered**: utility class in `globals.css` that gives a surface its e-ink-mode
|
||||
contrast border. Opt-in.
|
||||
- **Pill ghost**: circular icon button, `btn-ghost btn-circle`.
|
||||
|
||||
---
|
||||
|
||||
### 13. Maintenance
|
||||
|
||||
This doc is the **source of truth** for new design decisions. When the system grows:
|
||||
|
||||
- New archetypes get a numbered subsection in §4 or §5.
|
||||
- New anti-patterns get added to §10 with a real source reference.
|
||||
- Updates to existing principles require a brief why-changed note in the relevant section.
|
||||
|
||||
Cross-references that must stay in sync:
|
||||
|
||||
- `CLAUDE.md` E-ink mode section → §8 of this doc.
|
||||
- `docs/safe-area-insets.md` → §9 (cross-platform).
|
||||
- `src/styles/globals.css` `[data-eink]` rules → §8.
|
||||
- `src/styles/themes.ts` Palette type → §3 token table.
|
||||
|
||||
If you change a rule here, search for the cross-reference and update both.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Benchmarks
|
||||
|
||||
Manual performance benchmarks for the readest-app. **Not run in CI** — CI runners
|
||||
have shared-tenant variance that makes performance regression detection unreliable
|
||||
(numbers swing 2-10× between runs). These exist so anyone considering an
|
||||
architecture change can produce reproducible before/after numbers on their own
|
||||
hardware.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm bench # run every bench/*.bench.ts
|
||||
pnpm bench vector-retrieval # run a single benchmark by name
|
||||
pnpm bench --no-record # run but don't append to bench/results.jsonl
|
||||
pnpm bench --list # list available benchmarks
|
||||
```
|
||||
|
||||
Refuses to run when `$CI` is set. Append `--force` to override (don't unless
|
||||
you've explicitly opted into running benches in CI for a one-off investigation).
|
||||
|
||||
## Output
|
||||
|
||||
Each run prints a header with machine info (platform, CPU, Node version, key
|
||||
package versions) followed by per-benchmark results. By default, results are
|
||||
also appended to `bench/results.jsonl` (gitignored) — your personal local
|
||||
history. To share numbers, paste the table from the terminal into a PR or issue.
|
||||
|
||||
## When to add a new benchmark
|
||||
|
||||
When you're proposing an architecture change and need numbers to defend it. The
|
||||
benchmark should:
|
||||
|
||||
1. Live at `bench/<name>.bench.ts`.
|
||||
2. Export `default { name, description, run(ctx) }` matching the type in `lib.ts`.
|
||||
3. Print human-readable results to stdout and return structured results to the
|
||||
harness so they get logged to `results.jsonl`.
|
||||
4. Be self-contained — no fixtures outside `bench/`, no I/O outside the bench
|
||||
directory and an in-memory database.
|
||||
5. Run in under ~30 seconds at default sample sizes. If you need long-running
|
||||
scenarios, gate them behind a CLI flag.
|
||||
|
||||
## When *not* to add a benchmark
|
||||
|
||||
- "Just in case" — performance infrastructure has carrying cost. Wait until
|
||||
you have a real architecture question that numbers will answer.
|
||||
- To benchmark upstream libraries' performance (e.g., raw Turso function
|
||||
throughput). That belongs in the upstream project's bench suite.
|
||||
- To gate CI on performance thresholds. CI variance makes that flaky; use
|
||||
production telemetry (`reedy_metrics` table) for regression detection
|
||||
against real workloads.
|
||||
|
||||
## Existing benchmarks
|
||||
|
||||
- **`vector-retrieval`** — proves Turso's brute-force vector search is
|
||||
SIMD-accelerated and fast enough for Reedy MVP corpus sizes (sub-millisecond
|
||||
at 400 chunks × 768 dim, ~14 ms at 10K chunks × 768 dim). Established the
|
||||
decision in plan §M1.5 to skip ANN indexes (which Turso doesn't ship anyway).
|
||||
@@ -1,115 +0,0 @@
|
||||
import { readdirSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { type Bench, type BenchResult, formatHeader, formatResults, machineInfo } from './lib.ts';
|
||||
|
||||
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const RESULTS_FILE = join(BENCH_DIR, 'results.jsonl');
|
||||
|
||||
interface Args {
|
||||
filter: string | null;
|
||||
record: boolean;
|
||||
list: boolean;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = { filter: null, record: true, list: false, force: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === '--no-record') args.record = false;
|
||||
else if (arg === '--list') args.list = true;
|
||||
else if (arg === '--force') args.force = true;
|
||||
else if (!arg.startsWith('--')) args.filter = arg;
|
||||
else {
|
||||
console.error(`Unknown flag: ${arg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function loadBenches(): Promise<Bench[]> {
|
||||
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith('.bench.ts'));
|
||||
const benches: Bench[] = [];
|
||||
for (const file of files) {
|
||||
const mod = await import(resolve(BENCH_DIR, file));
|
||||
const bench = mod.default as Bench;
|
||||
if (!bench || typeof bench.run !== 'function') {
|
||||
console.error(`Skipping ${file}: no default export with .run()`);
|
||||
continue;
|
||||
}
|
||||
benches.push(bench);
|
||||
}
|
||||
return benches.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function gitCommit(): string {
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function recordRun(bench: Bench, results: BenchResult[], commit: string): void {
|
||||
if (!existsSync(BENCH_DIR)) mkdirSync(BENCH_DIR, { recursive: true });
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
commit,
|
||||
bench: bench.name,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
results,
|
||||
};
|
||||
appendFileSync(RESULTS_FILE, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (process.env['CI'] && !args.force) {
|
||||
console.error('Refusing to run benchmarks in CI (CI=' + process.env['CI'] + ').');
|
||||
console.error('Pass --force if you really mean it. See bench/README.md for why.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const benches = await loadBenches();
|
||||
|
||||
if (args.list) {
|
||||
console.log('Available benchmarks:');
|
||||
for (const b of benches) console.log(` ${b.name.padEnd(20)} ${b.description}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = args.filter ? benches.filter((b) => b.name === args.filter) : benches;
|
||||
if (selected.length === 0) {
|
||||
console.error(`No benchmark named "${args.filter}". Try --list.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const info = machineInfo(['@tursodatabase/database', '@readest/turso-database-wasm']);
|
||||
console.log(formatHeader(info));
|
||||
|
||||
const commit = gitCommit();
|
||||
console.log(` git commit : ${commit}`);
|
||||
console.log(` recording : ${args.record ? RESULTS_FILE : 'disabled (--no-record)'}`);
|
||||
console.log('═'.repeat(70));
|
||||
|
||||
for (const bench of selected) {
|
||||
process.stdout.write(`Running ${bench.name}... `);
|
||||
const t0 = performance.now();
|
||||
const results = await bench.run({ verbose: false });
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
console.log(`done in ${elapsed}s`);
|
||||
console.log(formatResults(bench.name, results));
|
||||
if (args.record) recordRun(bench, results, commit);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { cpus, platform, arch, totalmem } from 'node:os';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface BenchContext {
|
||||
/** Whether to print verbose per-iteration info. */
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export interface BenchResult {
|
||||
scenario: string;
|
||||
unit: 'ms' | 'us' | 'ns';
|
||||
value: number;
|
||||
/** Optional metadata: chunk count, dim, etc. */
|
||||
meta?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface Bench {
|
||||
name: string;
|
||||
description: string;
|
||||
run(ctx: BenchContext): Promise<BenchResult[]>;
|
||||
}
|
||||
|
||||
/** High-resolution timer; returns elapsed milliseconds. */
|
||||
export async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; ms: number }> {
|
||||
const t0 = performance.now();
|
||||
const result = await fn();
|
||||
const ms = performance.now() - t0;
|
||||
return { result, ms };
|
||||
}
|
||||
|
||||
/** Run `fn` `reps` times, return average milliseconds (after warmup). */
|
||||
export async function avg(fn: () => Promise<unknown>, reps: number, warmup = 3): Promise<number> {
|
||||
for (let i = 0; i < warmup; i++) await fn();
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < reps; i++) await fn();
|
||||
return (performance.now() - t0) / reps;
|
||||
}
|
||||
|
||||
/** Generate a random unit vector serialized as JSON, suitable for `vector32(?)`. */
|
||||
export function randomUnitVectorJson(dim: number): string {
|
||||
const v = new Float32Array(dim);
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) {
|
||||
const x = Math.random() * 2 - 1;
|
||||
v[i] = x;
|
||||
norm += x * x;
|
||||
}
|
||||
const inv = 1 / Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) v[i] = (v[i] ?? 0) * inv;
|
||||
return JSON.stringify(Array.from(v));
|
||||
}
|
||||
|
||||
export interface MachineInfo {
|
||||
platform: string;
|
||||
arch: string;
|
||||
cpu: string;
|
||||
cpuCount: number;
|
||||
memGiB: number;
|
||||
node: string;
|
||||
packages: Record<string, string>;
|
||||
}
|
||||
|
||||
export function machineInfo(packages: string[] = []): MachineInfo {
|
||||
const cpuList = cpus();
|
||||
const firstCpu = cpuList[0];
|
||||
const versions: Record<string, string> = {};
|
||||
for (const name of packages) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(`./node_modules/${name}/package.json`, 'utf8'));
|
||||
versions[name] = pkg.version;
|
||||
} catch {
|
||||
versions[name] = 'not installed';
|
||||
}
|
||||
}
|
||||
return {
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
cpu: firstCpu?.model.trim() ?? 'unknown',
|
||||
cpuCount: cpuList.length,
|
||||
memGiB: Math.round(totalmem() / 1024 ** 3),
|
||||
node: process.version,
|
||||
packages: versions,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatHeader(info: MachineInfo): string {
|
||||
const lines = [
|
||||
'═'.repeat(70),
|
||||
` Platform : ${info.platform}/${info.arch}`,
|
||||
` CPU : ${info.cpu} (${info.cpuCount} cores)`,
|
||||
` Memory : ${info.memGiB} GiB`,
|
||||
` Node : ${info.node}`,
|
||||
];
|
||||
for (const [name, ver] of Object.entries(info.packages)) {
|
||||
lines.push(` ${name.padEnd(9)}: ${ver}`);
|
||||
}
|
||||
lines.push('═'.repeat(70));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatResults(benchName: string, results: BenchResult[]): string {
|
||||
const lines = [`\n[${benchName}]`];
|
||||
for (const r of results) {
|
||||
const metaStr = r.meta
|
||||
? ` ${Object.entries(r.meta)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(' ')}`
|
||||
: '';
|
||||
lines.push(` ${r.scenario.padEnd(40)} ${formatValue(r.value, r.unit).padStart(12)}${metaStr}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: 'ms' | 'us' | 'ns'): string {
|
||||
if (unit === 'ms') return `${value.toFixed(3)} ms`;
|
||||
if (unit === 'us') return `${value.toFixed(2)} µs`;
|
||||
return `${value.toFixed(0)} ns`;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { avg, randomUnitVectorJson, type Bench, type BenchResult } from './lib.ts';
|
||||
|
||||
/**
|
||||
* Vector-retrieval brute-force kNN benchmark.
|
||||
*
|
||||
* Reedy MVP retrieval (see plan §M1.5) issues:
|
||||
*
|
||||
* SELECT id, vector_distance_cos(embedding, vector32(?)) AS d
|
||||
* FROM reedy_book_chunk_embeddings
|
||||
* WHERE book_hash = ?
|
||||
* ORDER BY d ASC LIMIT k
|
||||
*
|
||||
* Why this matters: Turso has no native vector index module
|
||||
* (`libsql_vector_idx` / `vector_top_k` don't exist — confirmed against
|
||||
* @tursodatabase/database@0.6.0-pre.28 and acknowledged upstream:
|
||||
* tursodatabase/turso#832 closed not-planned, #3778 proposed brute-force-first
|
||||
* which shipped at commit 1aba105df4f). The brute-force path with
|
||||
* SIMD-accelerated `vector_distance_cos` is what we ship; this bench tracks
|
||||
* its per-query latency at realistic MVP corpus sizes.
|
||||
*
|
||||
* Run it after upgrading @tursodatabase/database, after touching
|
||||
* BookRetriever's SQL shape, or when evaluating an architecture change
|
||||
* (ANN extension, quantization, engine swap).
|
||||
*/
|
||||
export default {
|
||||
name: 'vector-retrieval',
|
||||
description: 'Brute-force per-book kNN over vector32 embeddings filtered by book_hash.',
|
||||
|
||||
async run(): Promise<BenchResult[]> {
|
||||
const db = await connect(':memory:', {});
|
||||
await db.exec(
|
||||
'CREATE TABLE c (id INTEGER PRIMARY KEY, book_hash TEXT NOT NULL, embedding BLOB)',
|
||||
);
|
||||
await db.exec('CREATE INDEX idx_c_book ON c(book_hash)');
|
||||
|
||||
// (dim, chunks-per-book) matrix. Two books per scenario so the WHERE filter
|
||||
// does real work; we measure only the active-book query.
|
||||
const scenarios = [
|
||||
{ dim: 384, chunks: 400 }, // small book, light embedding (e5-small-v2)
|
||||
{ dim: 768, chunks: 400 }, // typical novel @ nomic-embed-text
|
||||
{ dim: 768, chunks: 2000 }, // long novel
|
||||
{ dim: 768, chunks: 10000 }, // multi-volume / textbook
|
||||
{ dim: 1536, chunks: 400 }, // text-embedding-3-small
|
||||
];
|
||||
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const { dim, chunks } of scenarios) {
|
||||
await db.exec('DELETE FROM c');
|
||||
|
||||
const insertA = await db.prepare(
|
||||
"INSERT INTO c (book_hash, embedding) VALUES ('book_a', vector32(?))",
|
||||
);
|
||||
for (let i = 0; i < chunks; i++) await insertA.run(randomUnitVectorJson(dim));
|
||||
|
||||
const insertB = await db.prepare(
|
||||
"INSERT INTO c (book_hash, embedding) VALUES ('book_b', vector32(?))",
|
||||
);
|
||||
for (let i = 0; i < chunks; i++) await insertB.run(randomUnitVectorJson(dim));
|
||||
|
||||
const query = randomUnitVectorJson(dim);
|
||||
// Embed the query vector literally so SIMD has the same memory layout
|
||||
// every call (mirrors the BookRetriever code path which serializes the
|
||||
// query embedding inline at the value-binding position).
|
||||
const sql = `
|
||||
SELECT id, vector_distance_cos(embedding, vector32('${query}')) AS d
|
||||
FROM c
|
||||
WHERE book_hash = ?
|
||||
ORDER BY d ASC
|
||||
LIMIT 5
|
||||
`;
|
||||
const stmt = await db.prepare(sql);
|
||||
|
||||
const ms = await avg(() => stmt.all('book_a'), 20);
|
||||
results.push({
|
||||
scenario: `${chunks} chunks × ${dim} dim`,
|
||||
unit: 'ms',
|
||||
value: ms,
|
||||
meta: { chunks, dim, usPerChunk: ((ms * 1000) / chunks).toFixed(2) },
|
||||
});
|
||||
}
|
||||
|
||||
await db.close();
|
||||
return results;
|
||||
},
|
||||
} satisfies Bench;
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/libs",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
# Readest Architecture
|
||||
|
||||
This document gives a system-level view of Readest: how the pieces fit together,
|
||||
which side of the wire each piece runs on, and what each module is responsible
|
||||
for. It complements [`code-layout.md`](./code-layout.md), which focuses on the
|
||||
directory layout. Read this one first if you want to understand the system; read
|
||||
that one when you need to find a specific file.
|
||||
|
||||
The diagrams use [Mermaid](https://mermaid.js.org/) and render natively on
|
||||
GitHub.
|
||||
|
||||
## 1. High-level picture
|
||||
|
||||
Readest is a single TypeScript/React codebase (`apps/readest-app`) compiled into
|
||||
multiple targets:
|
||||
|
||||
- a **desktop app** (Windows / macOS / Linux) via Tauri v2
|
||||
- a **mobile app** (Android / iOS) via Tauri v2 mobile
|
||||
- a **web app** running on Next.js / Cloudflare Workers (OpenNext) at
|
||||
[web.readest.com](https://web.readest.com)
|
||||
- two **side surfaces**: a "Send to Readest" browser extension
|
||||
(`apps/readest-app/extension/send-to-readest`) and a Windows thumbnail
|
||||
shell extension (`apps/readest-app/extensions/windows-thumbnail`)
|
||||
|
||||
The same React UI runs in all targets. What differs is the **host shell** under
|
||||
the UI and the **set of services** that the UI binds to at runtime — see
|
||||
section 4.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Clients
|
||||
Desktop["Desktop app<br/>(Tauri shell + React UI)"]
|
||||
Mobile["Mobile app<br/>(Tauri Android/iOS + React UI)"]
|
||||
Web["Web app<br/>(Next.js + React UI)"]
|
||||
Ext["Browser extension<br/>(Send to Readest)"]
|
||||
WinExt["Windows shell ext<br/>(thumbnail provider)"]
|
||||
end
|
||||
|
||||
subgraph Backend["Readest backend (Next.js routes + Cloudflare Worker)"]
|
||||
AppApi["src/app/api/*<br/>(App Router)"]
|
||||
PagesApi["src/pages/api/*<br/>(Pages Router)"]
|
||||
RuntimeCfg["/runtime-config.js<br/>(server-injected config)"]
|
||||
Worker["workers/send-email<br/>(Cloudflare Worker)"]
|
||||
end
|
||||
|
||||
subgraph Cloud["External services"]
|
||||
Supabase["Supabase<br/>(auth + Postgres)"]
|
||||
S3["Object storage<br/>(S3 / R2)"]
|
||||
Stripe["Stripe<br/>(billing)"]
|
||||
AI["AI providers<br/>(OpenAI / Ollama / ...)"]
|
||||
Trans["Translators<br/>(DeepL / Google / Azure / Yandex)"]
|
||||
Meta["Metadata providers<br/>(Google Books / Open Library)"]
|
||||
Dict["Dictionary sources<br/>(Wikipedia / Wiktionary / StarDict)"]
|
||||
OPDS["OPDS catalogs / Calibre"]
|
||||
Hardcover["Hardcover GraphQL"]
|
||||
Readwise["Readwise"]
|
||||
TTS["Edge TTS"]
|
||||
IAP["Apple / Google IAP"]
|
||||
end
|
||||
|
||||
Desktop --> Backend
|
||||
Mobile --> Backend
|
||||
Web --> Backend
|
||||
Ext --> PagesApi
|
||||
WinExt -.reads files.-> Desktop
|
||||
|
||||
PagesApi --> Supabase
|
||||
PagesApi --> S3
|
||||
PagesApi --> Trans
|
||||
AppApi --> Supabase
|
||||
AppApi --> Stripe
|
||||
AppApi --> AI
|
||||
AppApi --> Meta
|
||||
AppApi --> OPDS
|
||||
AppApi --> Hardcover
|
||||
AppApi --> TTS
|
||||
AppApi --> IAP
|
||||
|
||||
Web -.direct.-> Dict
|
||||
Desktop -.direct.-> Dict
|
||||
Mobile -.direct.-> Dict
|
||||
Web -.direct.-> Readwise
|
||||
Desktop -.direct.-> Readwise
|
||||
```
|
||||
|
||||
The `Backend` box is **the same code on all clients**. In the web target it is
|
||||
deployed as a Cloudflare Worker (via `@opennextjs/cloudflare` and
|
||||
`wrangler.toml`). In the Tauri targets the same routes are served by a Next.js
|
||||
runtime, but most clients hit the production deployment over HTTPS.
|
||||
|
||||
## 2. Process boundaries
|
||||
|
||||
There are three runtimes in play:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser["Web runtime (browser / Tauri webview)"]
|
||||
UI["React UI<br/>(src/app, src/components, src/hooks, src/store)"]
|
||||
Domain["Shared domain layer<br/>(src/services, src/utils, src/libs)"]
|
||||
Foliate["foliate-js<br/>(packages/foliate-js)"]
|
||||
SW["Service worker (sw.ts)"]
|
||||
TursoWasm["Turso WASM<br/>(replica DB in browser)"]
|
||||
end
|
||||
|
||||
subgraph Native["Tauri native host (Rust)"]
|
||||
TauriCore["src-tauri/src/lib.rs<br/>(commands, dir_scanner, transfer_file, clip_url, discord_rpc)"]
|
||||
Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, deep-link, opener, updater,<br/>native-bridge, native-tts, turso, webview-upgrade)"]
|
||||
end
|
||||
|
||||
subgraph Server["Next.js server (Worker / Node)"]
|
||||
Routes["App Router + Pages Router routes"]
|
||||
Mw["middleware.ts<br/>(CORS + COOP/COEP)"]
|
||||
RuntimeRoute["app/runtime-config.js<br/>(server-rendered config script)"]
|
||||
end
|
||||
|
||||
UI --> Domain
|
||||
Domain --> Foliate
|
||||
UI --> SW
|
||||
Domain --> TursoWasm
|
||||
|
||||
Domain -- "@tauri-apps/api invoke()" --> TauriCore
|
||||
TauriCore --> Plugins
|
||||
|
||||
Domain -- "fetch(/api/...)" --> Routes
|
||||
Browser -- "<script src=/runtime-config.js>" --> RuntimeRoute
|
||||
Routes --> Mw
|
||||
```
|
||||
|
||||
Three things are worth calling out:
|
||||
|
||||
The same `src/services/*` code runs on both sides of the `invoke()` boundary on
|
||||
desktop/mobile and on both sides of the `fetch()` boundary on web. Which
|
||||
implementation is picked is decided at runtime by `src/services/environment.ts`
|
||||
plus the platform-specific `*AppService.ts` (`webAppService`, `nativeAppService`,
|
||||
`nodeAppService`) — see section 4.
|
||||
|
||||
`middleware.ts` does two things and only two things: CORS for `/api/*`, and
|
||||
`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy:
|
||||
require-corp` on every document. The COOP/COEP pair is required so that the
|
||||
browser exposes `SharedArrayBuffer`, which the Turso WASM thread pool needs in
|
||||
order to run the in-browser replica database; without those headers
|
||||
`initThreadPool` hangs.
|
||||
|
||||
`/runtime-config.js` is a server route that emits
|
||||
`window.__READEST_RUNTIME_CONFIG = {...}` as a JavaScript file. It is loaded as
|
||||
a `<script>` tag from `app/layout.tsx` and `pages/_document.tsx`. This is what
|
||||
lets a single Docker image be rebranded with a different Supabase project, S3
|
||||
endpoint, or quota at deploy time without rebuilding — see commit
|
||||
`9ad43aa8` and the `docker/` directory.
|
||||
|
||||
## 3. Frontend architecture
|
||||
|
||||
The frontend is a Next.js 16 + React 19 app. It uses both routers:
|
||||
|
||||
| Concern | Lives in | Why |
|
||||
|---|---|---|
|
||||
| Library, reader, auth, OPDS, send, user pages | `src/app/*` (App Router) | Standard for new pages; supports server components and the runtime-config route. |
|
||||
| Reader entry by ID list `/reader/[ids]` | `src/pages/reader/[ids].tsx` (Pages Router) | Historical entrypoint; coexists with the App Router reader. |
|
||||
| Cross-origin isolation document shell | `src/pages/_document.tsx` | Pages Router still owns `<Document>` for COOP/COEP and `runtime-config.js`. |
|
||||
| HTTP API endpoints | both `src/app/api/*` and `src/pages/api/*` | Mix of new App Router routes and legacy Pages Router routes. |
|
||||
|
||||
### 3.1 UI module map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Layout["app/layout.tsx<br/>(root shell, runtime-config script, Providers)"]
|
||||
Library["app/library<br/>(grid, import, sort, OPDS shelf)"]
|
||||
Reader["app/reader<br/>(views + tooling)"]
|
||||
Auth["app/auth<br/>(Supabase auth UI)"]
|
||||
Send["app/send<br/>(send-to-Readest inbox)"]
|
||||
User["app/user<br/>(account, subscription, settings)"]
|
||||
Updater["app/updater"]
|
||||
Offline["app/offline"]
|
||||
OPDS["app/opds<br/>(catalog browser)"]
|
||||
Share["app/s, app/o<br/>(share landing pages)"]
|
||||
|
||||
Layout --> Library
|
||||
Layout --> Reader
|
||||
Layout --> Auth
|
||||
Layout --> Send
|
||||
Layout --> User
|
||||
Layout --> OPDS
|
||||
|
||||
subgraph ReaderInternals["app/reader internals"]
|
||||
ReaderPage["page.tsx"]
|
||||
ReaderComps["components/*<br/>(BookView, Sidebar, Notebook,<br/>Annotator, FootnotePopup, Translator,<br/>RSVP overlay, AIChat, ParallelView, ...)"]
|
||||
ReaderHooks["hooks/*<br/>(useFoliateEvents, useScrollHandler,<br/>useProgressSync, useAnnotations, ...)"]
|
||||
ReaderUtils["utils/*"]
|
||||
end
|
||||
Reader --> ReaderInternals
|
||||
|
||||
subgraph Shared["Shared UI primitives"]
|
||||
Components["components/*<br/>(Button, Dialog, Menu, Toast,<br/>BookCover, AppLockScreen, ...)"]
|
||||
Settings["components/settings/*<br/>(Layout/Font/Color/Custom panels)"]
|
||||
Assistant["components/assistant/*<br/>(AI chat composer)"]
|
||||
CmdPalette["components/command-palette"]
|
||||
end
|
||||
```
|
||||
|
||||
The biggest UI cluster by far is `app/reader`: roughly 80 components and 30
|
||||
hooks coordinating Foliate-based rendering, annotations, footnote popovers, the
|
||||
notebook side panel, parallel view, RSVP, AI chat, translator overlays,
|
||||
search, TTS, and the settings panels under `components/settings`.
|
||||
|
||||
### 3.2 State (Zustand)
|
||||
|
||||
Frontend state is split across single-purpose Zustand stores in `src/store`.
|
||||
Each store maps to a clearly delimited concern, which keeps the reader from
|
||||
collapsing into one mega-context:
|
||||
|
||||
```
|
||||
libraryStore -> books, folders, selection, sort
|
||||
bookDataStore -> per-book data (TOC, annotations, locations)
|
||||
readerStore -> active views, layout, ribbon state
|
||||
parallelViewStore -> two-pane reading
|
||||
notebookStore -> notebook side panel
|
||||
settingsStore -> user/app settings
|
||||
themeStore -> light/dark/atmosphere
|
||||
sidebarStore -> sidebar visibility/width
|
||||
trafficLightStore -> macOS traffic-light positioning
|
||||
appLockStore -> app PIN lock
|
||||
deviceStore -> device profile
|
||||
transferStore -> in-flight uploads/downloads
|
||||
aiChatStore -> AI chat sessions
|
||||
proofreadStore -> proofread side flow
|
||||
atmosphereStore -> ambient overlay
|
||||
customDictionaryStore / customFontStore /
|
||||
customTextureStore / customOPDSStore -> user-imported assets
|
||||
```
|
||||
|
||||
### 3.3 In-browser book engine
|
||||
|
||||
EPUB / MOBI / KF8 / FB2 / CBZ / TXT / PDF parsing and rendering is **not**
|
||||
hand-rolled in this repo. The reader sits on top of `packages/foliate-js`, a
|
||||
forked copy of the Foliate JS engine. Readest's reader code in `app/reader` and
|
||||
the adapters under `src/services/annotation`, `src/services/nav`,
|
||||
`src/services/transformers`, and `src/services/rsvp` wrap that engine and add
|
||||
features (annotations sync, navigation, content transforms, vertical/Warichu
|
||||
support, classic mode overlays, etc.).
|
||||
|
||||
PDF rendering goes through `pdfjs-dist`, which is copied into
|
||||
`public/vendor/pdfjs` at build time (`pnpm setup-pdfjs`). Chinese conversion
|
||||
uses `simplecc-wasm` (`public/vendor/simplecc`), and Chinese segmentation uses
|
||||
`jieba-wasm` (`public/vendor/jieba`).
|
||||
|
||||
### 3.4 Service worker and offline
|
||||
|
||||
`src/sw.ts` is a Serwist service worker that gives the web build offline
|
||||
support: cached static assets, cached API responses for read-only data, and an
|
||||
offline route at `/offline`.
|
||||
|
||||
## 4. The platform abstraction (`AppService`)
|
||||
|
||||
The single most important abstraction in the codebase is
|
||||
`src/services/appService.ts`. Every piece of code that touches "the platform"
|
||||
(file system, native dialogs, shell open, native TTS, IAP, dir scanning,
|
||||
deep links, etc.) goes through an `AppService` interface. There are three
|
||||
implementations:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Caller["UI code, hooks, services"]
|
||||
AppSvc["AppService interface<br/>(services/appService.ts)"]
|
||||
Native["nativeAppService.ts<br/>(Tauri desktop + mobile)"]
|
||||
Web["webAppService.ts<br/>(browser / web build)"]
|
||||
Node["nodeAppService.ts<br/>(Node tooling, tests, CLI)"]
|
||||
|
||||
Caller --> AppSvc
|
||||
AppSvc --> Native
|
||||
AppSvc --> Web
|
||||
AppSvc --> Node
|
||||
|
||||
Native -- "@tauri-apps/api invoke()" --> Rust["src-tauri Rust commands"]
|
||||
Native --> Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, native-bridge, native-tts, turso)"]
|
||||
Web --> Browser["browser APIs (File, IndexedDB, fetch)"]
|
||||
Web --> RemoteAPI["fetch() to /api/*"]
|
||||
Node --> Fs["node:fs, node:path"]
|
||||
```
|
||||
|
||||
`environment.ts` decides at runtime which implementation to mount, based on the
|
||||
build target (`NEXT_PUBLIC_APP_PLATFORM`) and runtime detection (`window`,
|
||||
Tauri injection). Most callers in the codebase do
|
||||
`const appService = useEnv().appService` and never know which one they got.
|
||||
|
||||
The same pattern repeats for the database layer in `src/services/database`:
|
||||
`webDatabaseService` (browser via Turso WASM), `nativeDatabaseService` (Tauri
|
||||
via the `tauri-plugin-turso` plugin), and `nodeDatabaseService` (Node, used by
|
||||
tests). All three share `migrate.ts` and `migrations/*`.
|
||||
|
||||
This is why most domain code in `src/services` looks platform-agnostic — the
|
||||
platform difference has been pushed to a small number of seams.
|
||||
|
||||
## 5. Backend (Next.js routes)
|
||||
|
||||
There are two route trees because of historical mix between App Router and
|
||||
Pages Router. The split is pragmatic, not load-bearing: new routes go to
|
||||
`src/app/api`, legacy/sync/storage live in `src/pages/api`.
|
||||
|
||||
### 5.1 Pages Router endpoints (`src/pages/api`)
|
||||
|
||||
These are the long-standing server endpoints around sync, storage, and email:
|
||||
|
||||
```
|
||||
sync.ts -> KOReader-compatible sync client (`KOSyncClient`)
|
||||
kosync.ts -> KOSync legacy bridge
|
||||
sync/replicas.ts -> replica sync upload/download (encrypted blobs)
|
||||
sync/replica-keys.ts -> replica key bootstrap
|
||||
storage/upload.ts -> presigned upload to S3/R2 for book bytes
|
||||
storage/download.ts -> presigned download
|
||||
storage/list.ts -> list user's objects
|
||||
storage/delete.ts -> delete a single object
|
||||
storage/purge.ts -> bulk wipe (account deletion path)
|
||||
storage/stats.ts -> per-user usage/quotas
|
||||
send/inbox.ts -> "Send to Readest" inbox listing
|
||||
send/inbox/* -> inbox item operations
|
||||
send/address.ts -> per-user inbox address resolver
|
||||
send/fetch-url.ts -> server-side URL fetcher for "send a link"
|
||||
send/senders.ts -> sender allowlist
|
||||
deepl/translate.ts -> DeepL translation proxy (hides API key)
|
||||
user/delete.ts -> account deletion
|
||||
```
|
||||
|
||||
The storage layer talks to S3-compatible storage through `src/utils/s3.ts`,
|
||||
which honors a `S3_PUBLIC_ENDPOINT` distinct from the internal endpoint so
|
||||
docker-compose deployments can route browsers through one origin and the
|
||||
server through another.
|
||||
|
||||
### 5.2 App Router endpoints (`src/app/api`)
|
||||
|
||||
Newer endpoints, grouped by domain:
|
||||
|
||||
```
|
||||
ai/chat -> streaming AI chat (Vercel AI SDK)
|
||||
ai/embed -> embeddings for in-book RAG
|
||||
metadata/search -> metadata lookup (Google Books / Open Library)
|
||||
opds/proxy -> CORS-friendly OPDS proxy
|
||||
tts/edge -> Edge TTS streaming
|
||||
hardcover/graphql -> Hardcover GraphQL relay
|
||||
stripe/checkout -> create checkout session
|
||||
stripe/portal -> billing portal redirect
|
||||
stripe/plans -> plan listing
|
||||
stripe/check -> subscription state
|
||||
stripe/webhook -> Stripe webhook handler
|
||||
google/iap-verify -> Google Play IAP verification
|
||||
apple/iap-verify -> App Store IAP verification
|
||||
share/* -> share-link landing + read-only render
|
||||
```
|
||||
|
||||
### 5.3 Workers
|
||||
|
||||
`apps/readest-app/workers/send-email` is a separate Cloudflare Worker
|
||||
(deployed independently from the main app) responsible for the "Send to Readest
|
||||
by email" path. It receives mail, normalizes attachments, and drops items into
|
||||
the user's inbox so that the in-app `Send` page can pick them up via the
|
||||
`/api/send/inbox` endpoints.
|
||||
|
||||
### 5.4 Runtime config
|
||||
|
||||
`src/app/runtime-config.js/route.ts` is a server route that builds a small JSON
|
||||
object — `supabaseUrl`, `supabaseAnonKey`, `apiBaseUrl`, `objectStorageType`,
|
||||
`storageFixedQuota`, `translationFixedQuota` — from `process.env` at request
|
||||
time and serializes it as a JS payload. The client reads it through
|
||||
`getRuntimeConfig()` in `src/services/runtimeConfig.ts` (browser) or
|
||||
`getServerRuntimeConfig()` (server). This is the mechanism that makes the same
|
||||
prebuilt Docker image rebrandable per deployment.
|
||||
|
||||
## 6. Cross-cutting subsystems
|
||||
|
||||
These don't live in one file or one route; they span the frontend, the backend,
|
||||
and (sometimes) the native shell.
|
||||
|
||||
### 6.1 Sync
|
||||
|
||||
Two sync paths coexist:
|
||||
|
||||
The first is **legacy KOReader-compatible sync** for reading progress,
|
||||
implemented by `src/services/sync/KOSyncClient.ts` against `pages/api/sync.ts`
|
||||
and `pages/api/kosync.ts`. It exists for compatibility with KOSync-style
|
||||
clients.
|
||||
|
||||
The second is **replica sync**, the modern path. It encrypts each replica
|
||||
locally with a passphrase-derived key
|
||||
(`replicaCryptoMiddleware.ts`, `passphraseGate.ts`), publishes deltas to
|
||||
`pages/api/sync/replicas.ts`, pulls peer updates, and applies them through
|
||||
category adapters in `src/services/sync/adapters/*` (annotations, settings,
|
||||
dictionaries, fonts, textures, OPDS catalogs). The orchestrator is
|
||||
`replicaSyncManager.ts`. A cursor store (`replicaCursorStore.ts`) tracks "where
|
||||
I last pulled to" per category so syncs are incremental.
|
||||
|
||||
### 6.2 Cloud library
|
||||
|
||||
Distinct from replica sync. The cloud library handles **book bytes** (not
|
||||
metadata):
|
||||
|
||||
- import flow: `src/services/ingestService.ts` decides whether a book is
|
||||
imported as a hash copy under `Books/<hash>/` or kept *in place* at the
|
||||
user's chosen path (the "in-place" mode added in commit `dd107277`).
|
||||
- upload: `cloudService.uploadBook` uses the storage layer to push bytes to S3
|
||||
through `pages/api/storage/upload.ts`.
|
||||
- download: peers fetch via `pages/api/storage/download.ts`, materializing the
|
||||
book into `Books/<hash>/` regardless of whether the original device kept it
|
||||
in-place.
|
||||
- delete: symmetric local/cloud/both semantics in `cloudService.deleteBook`.
|
||||
|
||||
### 6.3 AI / RAG
|
||||
|
||||
`src/services/ai` provides the chat and embedding abstraction with provider
|
||||
adapters, prompt assembly, chunking, retry, and a local AI store. UI lives in
|
||||
`components/assistant` and the reader-side `app/reader/components/AIChat*`. The
|
||||
HTTP entrypoints are `src/app/api/ai/chat` (streaming) and
|
||||
`src/app/api/ai/embed`. The reader can do book-scoped RAG by embedding chapters
|
||||
locally and querying the embeddings store.
|
||||
|
||||
### 6.4 Translation
|
||||
|
||||
`src/services/translators` has provider adapters for DeepL, Google, Azure, and
|
||||
Yandex, plus a preprocess + cache + polish pipeline. DeepL goes through a
|
||||
server proxy (`pages/api/deepl/translate.ts`) to keep the API key server-side;
|
||||
the others can hit the providers directly from the client.
|
||||
|
||||
### 6.5 TTS
|
||||
|
||||
Three TTS backends behind one interface (`src/services/tts`):
|
||||
|
||||
- `WebSpeechClient` for browsers,
|
||||
- `NativeTTSClient` for Tauri via `tauri-plugin-native-tts`,
|
||||
- `EdgeTTSClient` going through `src/app/api/tts/edge` for streaming Microsoft
|
||||
Edge voices.
|
||||
|
||||
### 6.6 Dictionaries
|
||||
|
||||
`src/services/dictionaries` parses StarDict and SLOB packs locally
|
||||
(`readers/`), and integrates online sources (Wikipedia, Wiktionary,
|
||||
provider-specific). Lookup goes through a candidate generator + dedup so
|
||||
clicking a word finds all installed dictionaries and online sources in one
|
||||
roundtrip.
|
||||
|
||||
### 6.7 OPDS / Calibre
|
||||
|
||||
`src/services/opds` parses feeds, supports auto-download, and tracks
|
||||
subscription state. Cross-origin feeds are tunneled through
|
||||
`src/app/api/opds/proxy`. The library UI surfaces OPDS shelves alongside local
|
||||
books.
|
||||
|
||||
### 6.8 Third-party reading services
|
||||
|
||||
Hardcover (`src/services/hardcover` + `src/app/api/hardcover/graphql`) and
|
||||
Readwise (`src/services/readwise`) integrations let users export reading
|
||||
progress and highlights.
|
||||
|
||||
### 6.9 Annotations
|
||||
|
||||
`src/services/annotation` defines the canonical annotation model and provides
|
||||
adapters: a Foliate adapter (the default in-app representation) and an MR
|
||||
import/export adapter for moving annotations to and from MoonReader.
|
||||
|
||||
### 6.10 RSVP and content transforms
|
||||
|
||||
`src/services/rsvp` is the rapid-serial-visual-presentation reading mode.
|
||||
`src/services/transformers` contains pure functions for language detection,
|
||||
punctuation normalization, whitespace collapsing, proofread suggestions,
|
||||
sanitization, footnote rewriting, style injection, traditional/simplified
|
||||
Chinese conversion (via `simplecc-wasm`), and Warichu (Japanese ruby/rubi)
|
||||
layout. These are reused by the reader, by RSVP, and by the
|
||||
"Send to Readest" article-to-EPUB conversion.
|
||||
|
||||
### 6.11 Send to Readest
|
||||
|
||||
End-to-end pipeline:
|
||||
|
||||
1. The browser extension (`apps/readest-app/extension/send-to-readest`) or the
|
||||
email-to-inbox path (`workers/send-email`) submits a URL or article HTML.
|
||||
2. `src/services/send/conversion/*` sanitizes the content and converts it to
|
||||
EPUB (sanitization, TOC building, asset bundling, worker protocol).
|
||||
3. The result lands in the user's inbox served by `pages/api/send/inbox*`.
|
||||
4. The `app/send` page or the in-app inbox drainer
|
||||
(`src/services/send/inboxDrainer.ts`) imports it into the library through
|
||||
the standard ingest service.
|
||||
|
||||
## 7. Native shell (`src-tauri`)
|
||||
|
||||
The Tauri host is shared by desktop and mobile. The Rust side (`src-tauri/src`)
|
||||
is small and focused:
|
||||
|
||||
```
|
||||
lib.rs -> command registration, scope grants, deep links, builder
|
||||
main.rs -> entrypoint
|
||||
clip_url.rs -> clipboard URL extraction
|
||||
dir_scanner.rs -> recursive directory scan (used by library import)
|
||||
transfer_file.rs -> chunked upload/download for big files
|
||||
discord_rpc.rs -> Discord Rich Presence (desktop only)
|
||||
android/, macos/,
|
||||
windows/ -> per-platform glue
|
||||
```
|
||||
|
||||
Everything else is delegated to **Tauri plugins**, mostly bundled in
|
||||
`packages/tauri-plugins/plugins`:
|
||||
|
||||
- standard plugins: `fs`, `dialog`, `http`, `opener`, `os`, `process`, `shell`,
|
||||
`cli`, `deep-link`, `haptics`, `log`, `updater`, `websocket`, `oauth`,
|
||||
`persisted-scope`, `device-info`, `sharekit`
|
||||
- in-tree custom plugins:
|
||||
- `tauri-plugin-native-bridge` — Android-side bridges (directory picker
|
||||
callback, open external URL, etc.)
|
||||
- `tauri-plugin-native-tts` — native text-to-speech
|
||||
- `tauri-plugin-turso` — embedded Turso/libSQL database for the native
|
||||
targets, mirrored by the WASM build used in the browser
|
||||
- `tauri-plugin-webview-upgrade` — webview update flow on platforms where
|
||||
that matters
|
||||
|
||||
A subtle but important detail in `lib.rs`: `allow_paths_in_scopes` is the
|
||||
frontend-callable shim that extends both `fs_scope` and `asset_protocol_scope`
|
||||
**only for paths the Tauri dialog plugin (or persisted-scope on restart)
|
||||
already granted**. Without that gate, any frontend code path — including a
|
||||
hypothetical XSS through book content, OPDS HTML, or a compromised dependency —
|
||||
could grant itself read access to the user's home directory through the asset
|
||||
protocol. The gate constrains the command to user-picked paths only.
|
||||
|
||||
## 8. Build and deploy
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source["apps/readest-app (single source)"]
|
||||
|
||||
subgraph BuildTargets
|
||||
BWeb["next build<br/>+ @opennextjs/cloudflare<br/>(.env.web)"]
|
||||
BTauriDesk["next build → tauri build<br/>(.env.tauri)"]
|
||||
BTauriMob["next build → tauri android/ios build"]
|
||||
end
|
||||
|
||||
subgraph DeployTargets
|
||||
DCloudflare["Cloudflare Workers<br/>(web.readest.com)"]
|
||||
DDocker["Docker image<br/>(ghcr.io/readest/readest)"]
|
||||
DDesktop["dmg / nsis / appimage"]
|
||||
DMobile["aab / ipa"]
|
||||
DExt["browser extension package"]
|
||||
end
|
||||
|
||||
Source --> BWeb
|
||||
Source --> BTauriDesk
|
||||
Source --> BTauriMob
|
||||
|
||||
BWeb --> DCloudflare
|
||||
BWeb --> DDocker
|
||||
BTauriDesk --> DDesktop
|
||||
BTauriMob --> DMobile
|
||||
Source --> DExt
|
||||
```
|
||||
|
||||
The web target has two delivery modes: a Cloudflare Worker via OpenNext
|
||||
(`pnpm deploy`) and a self-hostable Docker image built and published from
|
||||
`.github/workflows/docker-image.yml` to GHCR and Docker Hub. The Docker image
|
||||
uses `docker/compose.yaml` (pull) plus `docker/compose.build.yaml` (build) and
|
||||
relies on the runtime-config mechanism described in section 5.4 so a single
|
||||
prebuilt image can be parameterized with `.env`.
|
||||
|
||||
Tauri builds use `dotenv` to switch env files (`.env.tauri`,
|
||||
`.env.tauri.local`, `.env.apple-*.local`, `.env.ios-*.local`,
|
||||
`.env.google-play.local`) for code-signing and store-specific configuration.
|
||||
Mobile and desktop produce installable bundles (dmg, nsis, appimage, aab, ipa).
|
||||
|
||||
## 9. Quick rule of thumb
|
||||
|
||||
When trying to place a piece of behavior, ask in this order:
|
||||
|
||||
Does it talk to a remote service or write to durable shared storage? Then it
|
||||
ends up in `src/pages/api` or `src/app/api`, possibly fronted by a service
|
||||
under `src/services`. Does it touch the user's filesystem, native dialogs, the
|
||||
shell, or system TTS? Then it goes through `appService` and lands in
|
||||
`nativeAppService` (Tauri commands in `src-tauri/src/lib.rs`) or
|
||||
`webAppService` (browser equivalent). Does it manipulate book content, render
|
||||
the reader, or maintain UI state? Then it lives under `src/app/reader`,
|
||||
`src/components`, `src/hooks`, `src/store`, or one of the reader-side service
|
||||
folders (`annotation`, `nav`, `rsvp`, `transformers`, `dictionaries`,
|
||||
`translators`, `tts`). Is it a sync or cloud-library concern? `src/services/sync`
|
||||
plus the matching API route, or `src/services/cloudService.ts` plus
|
||||
`pages/api/storage/*`.
|
||||
|
||||
If you can answer "which runtime owns this" in one sentence, you've placed the
|
||||
file correctly. If you can't, it's probably shared and belongs under
|
||||
`src/services`, `src/utils`, `src/libs`, or `src/types`.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Book Config JSON
|
||||
|
||||
Each imported book may have a per-book config file at:
|
||||
|
||||
```text
|
||||
<bookHash>/config.json
|
||||
```
|
||||
|
||||
The file is written under the `Books` storage root and uses the same camelCase
|
||||
keys as the TypeScript `BookConfig` type in `src/types/book.ts`.
|
||||
|
||||
## Version
|
||||
|
||||
`schemaVersion` identifies the raw `config.json` schema written by Readest.
|
||||
Current version:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
```
|
||||
|
||||
Configs written before this field existed are treated as legacy configs and are
|
||||
loaded as the current version. New writes must include `schemaVersion`.
|
||||
|
||||
`schemaVersion` is only for the raw disk JSON file. The cloud sync
|
||||
`book_configs` table is a normalized sync projection and does not mirror this
|
||||
field.
|
||||
|
||||
## Stable Fields
|
||||
|
||||
Version 1 documents these fields as the supported integration surface:
|
||||
|
||||
- `schemaVersion`: raw config schema version.
|
||||
- `bookHash`, `metaHash`: book identity when present.
|
||||
- `progress`: current page tuple, `[current, total]`.
|
||||
- `location`: current reading location as CFI.
|
||||
- `xpointer`: current reading location as XPointer for KOReader interoperability.
|
||||
- `booknotes`: bookmarks, annotations, and excerpts.
|
||||
- `rsvpPosition`: RSVP reading position.
|
||||
- `updatedAt`: last config update timestamp in milliseconds.
|
||||
|
||||
`viewSettings` and `searchConfig` are persisted app state. They are partial
|
||||
overrides and are merged with defaults when Readest loads the config.
|
||||
|
||||
## Notes and XPointer Fields
|
||||
|
||||
`BookConfig.xpointer` is the current reading location. It was not renamed by
|
||||
KOReader annotation sync work.
|
||||
|
||||
For notes in `booknotes`, Readest stores note ranges with:
|
||||
|
||||
- `xpointer0`: start XPointer.
|
||||
- `xpointer1`: end XPointer, when available.
|
||||
|
||||
This distinction matters for integrations reading raw config files: progress
|
||||
uses `xpointer`, while note ranges use `xpointer0` and `xpointer1`.
|
||||
@@ -1,341 +0,0 @@
|
||||
# Readest App Code Layout
|
||||
|
||||
This note summarizes the runtime boundaries inside `apps/readest-app`, with two goals:
|
||||
|
||||
- explain which directories are server-side, client-side, or mixed
|
||||
- explain the directory-level role of `apps/readest-app/src/services`
|
||||
|
||||
## First: `src-tauri`
|
||||
|
||||
`apps/readest-app/src-tauri` is the Tauri native shell layer for all Tauri targets, not just desktop.
|
||||
|
||||
- Desktop: Windows, macOS, Linux
|
||||
- Mobile: Android, iOS
|
||||
|
||||
That is visible in `apps/readest-app/src-tauri/tauri.conf.json`, which contains both `bundle.android` and `bundle.iOS` configuration, plus mobile deep-link settings.
|
||||
|
||||
So the rough split is:
|
||||
|
||||
- `apps/readest-app/src`: the Next.js/React app
|
||||
- `apps/readest-app/src-tauri`: the native host layer for Tauri desktop and mobile builds
|
||||
|
||||
## Directory classification inside `apps/readest-app`
|
||||
|
||||
### Mostly server-side directories
|
||||
|
||||
- `apps/readest-app/src/app/api`
|
||||
Next.js App Router server endpoints (`route.ts`). These run on the server / edge runtime, not in the browser.
|
||||
|
||||
- `apps/readest-app/src/pages/api`
|
||||
Next.js Pages Router API endpoints. This is where the classic server handlers live, including sync, storage, send, DeepL, and user endpoints.
|
||||
|
||||
- `apps/readest-app/src/app/runtime-config.js`
|
||||
A server route that emits runtime JavaScript config for the client.
|
||||
|
||||
- `apps/readest-app/workers`
|
||||
Worker-side backend code outside the normal page UI tree. For example, `workers/send-email` is operational backend code.
|
||||
|
||||
### Mostly client-side directories
|
||||
|
||||
- `apps/readest-app/src/components`
|
||||
Reusable React UI components.
|
||||
|
||||
- `apps/readest-app/src/context`
|
||||
React context providers and app state wiring.
|
||||
|
||||
- `apps/readest-app/src/hooks`
|
||||
Client-side React hooks.
|
||||
|
||||
- `apps/readest-app/src/store`
|
||||
Frontend state stores.
|
||||
|
||||
- `apps/readest-app/src/styles`
|
||||
Styling, theme assets, and UI presentation helpers.
|
||||
|
||||
- `apps/readest-app/src/data`
|
||||
Static or bundled app data.
|
||||
|
||||
- `apps/readest-app/src/i18n`
|
||||
Internationalization resources and setup.
|
||||
|
||||
- `apps/readest-app/src/workers`
|
||||
Browser worker code used by the frontend.
|
||||
|
||||
- `apps/readest-app/public`
|
||||
Static assets served to the frontend.
|
||||
|
||||
- `apps/readest-app/extension`
|
||||
Browser-extension-specific client code.
|
||||
|
||||
- `apps/readest-app/extensions`
|
||||
Platform integration extensions such as Windows thumbnail support.
|
||||
|
||||
### Mixed or shared directories
|
||||
|
||||
- `apps/readest-app/src/app`
|
||||
Mostly frontend routes and UI, but not purely client-side. In Next App Router, `page.tsx`, `layout.tsx`, and related files can mix server rendering and client components. The exception is `src/app/api`, which is server-only.
|
||||
|
||||
- `apps/readest-app/src/pages`
|
||||
Mixed. `src/pages/api` is server-only; `src/pages/reader/[ids].tsx` is frontend page code; `_document.tsx` is server-side document wiring.
|
||||
|
||||
- `apps/readest-app/src/services`
|
||||
Shared domain/service layer. Most of this is not “backend-only”; it contains platform adapters, client logic, network clients, sync logic, and some code that is reused by server routes.
|
||||
|
||||
- `apps/readest-app/src/utils`
|
||||
Shared helpers used by both frontend code and server handlers.
|
||||
|
||||
- `apps/readest-app/src/libs`
|
||||
Shared library code. Some of it is server-oriented, some client-oriented, some neutral.
|
||||
|
||||
- `apps/readest-app/src/helpers`
|
||||
General helper code, usually shared.
|
||||
|
||||
- `apps/readest-app/src/types`
|
||||
Shared type definitions.
|
||||
|
||||
- `apps/readest-app/src/__tests__`
|
||||
Test code covering both client and server behavior.
|
||||
|
||||
- `apps/readest-app/e2e`
|
||||
End-to-end test suite.
|
||||
|
||||
- `apps/readest-app/scripts`
|
||||
Build, release, and maintenance scripts.
|
||||
|
||||
- `apps/readest-app/docs`
|
||||
App-specific documentation.
|
||||
|
||||
|
||||
## `src/app` and `src/pages` at directory level
|
||||
|
||||
### `src/app`
|
||||
|
||||
- `src/app/api`: server-side HTTP endpoints
|
||||
- `src/app/auth`: auth pages and auth-related UI/helpers
|
||||
- `src/app/library`: library UI
|
||||
- `src/app/o`: frontend route
|
||||
- `src/app/offline`: frontend offline page
|
||||
- `src/app/opds`: OPDS browsing UI
|
||||
- `src/app/reader`: reader UI
|
||||
- `src/app/runtime-config.js`: server-generated runtime config endpoint
|
||||
- `src/app/s`: share landing UI
|
||||
- `src/app/send`: send/import UI
|
||||
- `src/app/updater`: updater UI
|
||||
- `src/app/user`: account/subscription/settings UI
|
||||
|
||||
So `src/app` is mostly application UI, with one explicitly server-only subtree: `src/app/api`, plus the runtime-config route.
|
||||
|
||||
### `src/pages`
|
||||
|
||||
- `src/pages/api`: server-side API routes
|
||||
- `src/pages/reader`: frontend page route(s)
|
||||
- `src/pages/_app.tsx`: application wrapper for Pages Router
|
||||
- `src/pages/_document.tsx`: server-side document shell
|
||||
|
||||
So `src/pages` is mixed, not purely client-side.
|
||||
|
||||
## `src/services` breakdown
|
||||
|
||||
The most important point is this:
|
||||
|
||||
- `src/services` is mostly a shared application/service layer
|
||||
- it is not the same thing as “backend code”
|
||||
- actual HTTP server entrypoints are mainly under `src/pages/api` and `src/app/api`
|
||||
|
||||
### Top-level files in `src/services`
|
||||
|
||||
- `appService.ts`
|
||||
Base application service abstraction.
|
||||
|
||||
- `nativeAppService.ts`
|
||||
Native/Tauri-facing app service implementation.
|
||||
|
||||
- `nodeAppService.ts`
|
||||
Node-capable service implementation.
|
||||
|
||||
- `webAppService.ts`
|
||||
Web/browser-oriented service implementation.
|
||||
|
||||
- `bookService.ts`
|
||||
Book-level operations such as covers, metadata shaping, and book-related domain logic.
|
||||
|
||||
- `libraryService.ts`
|
||||
Library management logic.
|
||||
|
||||
- `settingsService.ts`
|
||||
Reading and persisting settings.
|
||||
|
||||
- `backupService.ts`
|
||||
Backup/import-export related logic.
|
||||
|
||||
- `cloudService.ts`
|
||||
Cloud-related app behavior.
|
||||
|
||||
- `fontService.ts`
|
||||
Custom font handling.
|
||||
|
||||
- `imageService.ts`
|
||||
Image-related helper logic.
|
||||
|
||||
- `ingestService.ts`
|
||||
Import / ingest pipeline for incoming content.
|
||||
|
||||
- `persistence.ts`
|
||||
Shared persistence utilities.
|
||||
|
||||
- `transformService.ts`
|
||||
Content transformation entrypoints.
|
||||
|
||||
- `commandRegistry.ts`
|
||||
Command registration / dispatch.
|
||||
|
||||
- `transferManager.ts` and `transferMessages.ts`
|
||||
Transfer pipeline coordination.
|
||||
|
||||
- `environment.ts` and `runtimeConfig.ts`
|
||||
Runtime environment detection and injected runtime configuration.
|
||||
|
||||
- `constants.ts` and `errors.ts`
|
||||
Shared constants and error types.
|
||||
|
||||
These top-level files are mostly shared client/application-layer code, with some runtime branching for web, node, and Tauri.
|
||||
|
||||
### `src/services/database`
|
||||
|
||||
Platform-specific database access and migrations.
|
||||
|
||||
- `webDatabaseService.ts`: browser/web DB implementation
|
||||
- `nodeDatabaseService.ts`: Node-side DB implementation
|
||||
- `nativeDatabaseService.ts`: native/Tauri DB implementation
|
||||
- `migrate.ts` and `migrations/`: schema and migration logic
|
||||
|
||||
This is shared infrastructure code, not an HTTP backend directory.
|
||||
|
||||
### `src/services/sync`
|
||||
|
||||
Sync clients and replica-sync orchestration.
|
||||
|
||||
- legacy/remote sync client code such as `KOSyncClient.ts`
|
||||
- replica sync flow: bootstrap, publish, pull, apply, persistence, cursor storage, encryption, and passphrase handling
|
||||
- adapter subdirectory for sync categories such as dictionary, font, texture, OPDS catalog, and settings
|
||||
|
||||
This is mostly client-side sync orchestration talking to backend endpoints like `src/pages/api/sync.ts` and `src/pages/api/sync/replicas.ts`.
|
||||
|
||||
### `src/services/send`
|
||||
|
||||
“Send to Readest” and content conversion logic.
|
||||
|
||||
- `sendAddress.ts`, `devicePrefs.ts`, `inboxDrainer.ts`
|
||||
- `conversion/`: article/page-to-EPUB conversion pipeline, sanitization, TOC building, asset bundling, and worker protocol
|
||||
|
||||
This is mostly application logic used by frontend flows and server endpoints together.
|
||||
|
||||
### `src/services/metadata`
|
||||
|
||||
Book metadata lookup services.
|
||||
|
||||
- provider implementations like Google Books and Open Library
|
||||
- shared metadata types and orchestration service
|
||||
|
||||
This is shared integration logic. Actual HTTP exposure happens via route handlers such as `src/app/api/metadata/search`.
|
||||
|
||||
### `src/services/dictionaries`
|
||||
|
||||
Dictionary import, parsing, lookup, and provider registry.
|
||||
|
||||
- readers/parsers for StarDict, SLOB, and related formats
|
||||
- provider adapters for dictionary/web/wikipedia/wiktionary sources
|
||||
- dictionary service, deduplication, content ID, and lookup candidate generation
|
||||
|
||||
This is primarily client/application functionality.
|
||||
|
||||
### `src/services/annotation`
|
||||
|
||||
Annotation models and provider adapters.
|
||||
|
||||
- annotation types and normalization
|
||||
- provider adapters such as Foliate and MR export/import
|
||||
|
||||
Mostly shared reader-side logic.
|
||||
|
||||
### `src/services/nav`
|
||||
|
||||
Navigation, fragments, grouping, locations, and lookup utilities for books.
|
||||
|
||||
Mostly client-side reader logic.
|
||||
|
||||
### `src/services/opds`
|
||||
|
||||
OPDS feed handling and subscription state.
|
||||
|
||||
- feed parsing/checking
|
||||
- auto-download support
|
||||
- stream and subscription helpers
|
||||
|
||||
Mostly frontend/domain logic, sometimes paired with server proxy routes.
|
||||
|
||||
### `src/services/translators`
|
||||
|
||||
Translation provider integration.
|
||||
|
||||
- provider adapters for DeepL, Google, Azure, Yandex
|
||||
- preprocessing, cache, polish, and translator utilities
|
||||
|
||||
Mixed integration code. Some providers are used via server APIs to avoid exposing secrets.
|
||||
|
||||
### `src/services/tts`
|
||||
|
||||
Text-to-speech abstraction and implementations.
|
||||
|
||||
- `WebSpeechClient.ts`: browser TTS
|
||||
- `NativeTTSClient.ts`: native/Tauri TTS
|
||||
- `EdgeTTSClient.ts`: remote/provider-backed TTS
|
||||
- controller/data/types/utilities
|
||||
|
||||
Mixed runtime code, mostly used by the reader frontend.
|
||||
|
||||
### `src/services/ai`
|
||||
|
||||
AI chat/embedding/RAG related abstractions.
|
||||
|
||||
- adapters and providers
|
||||
- prompts, chunking, retry logic, logging
|
||||
- local AI store and RAG service
|
||||
|
||||
Mixed integration code. The services are shared, while actual HTTP endpoints live under `src/app/api/ai`.
|
||||
|
||||
### `src/services/hardcover` and `src/services/readwise`
|
||||
|
||||
Third-party reading service integrations.
|
||||
|
||||
- Hardcover sync client and mapping store
|
||||
- Readwise client integration
|
||||
|
||||
Mostly client/application integration code.
|
||||
|
||||
### `src/services/rsvp`
|
||||
|
||||
RSVP reader mode logic.
|
||||
|
||||
- controller, persistence, utilities, and types
|
||||
|
||||
Client-side reading feature code.
|
||||
|
||||
### `src/services/transformers`
|
||||
|
||||
Text/content transformation modules.
|
||||
|
||||
- language, punctuation, whitespace, proofread, sanitization, footnote, style, simplecc, warichu
|
||||
|
||||
Shared pure logic, usually frontend-facing but not tied to a single runtime.
|
||||
|
||||
## Practical mental model
|
||||
|
||||
If you want a fast rule of thumb for this repo, use this:
|
||||
|
||||
- HTTP backend entrypoints: `src/pages/api`, `src/app/api`, `workers`
|
||||
- frontend UI/routes: `src/app` except `api`, plus `src/components`, `src/hooks`, `src/store`
|
||||
- shared app/domain logic: `src/services`, `src/utils`, `src/libs`, `src/types`
|
||||
- native host layer for desktop + Android + iOS: `src-tauri`
|
||||
|
||||
That model matches the codebase much better than “everything under `src` is client code.”
|
||||
@@ -1,72 +0,0 @@
|
||||
## i18n Guide
|
||||
|
||||
Readest uses a **key-as-content** approach — English strings are the translation keys. The English locale (`en/translation.json`) is empty because keys serve as content. Other locales contain actual translations.
|
||||
|
||||
### In React Components
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
|
||||
const _ = useTranslation();
|
||||
_('Progress synced');
|
||||
```
|
||||
|
||||
### In Non-React Modules
|
||||
|
||||
Two-step process:
|
||||
|
||||
**1. Declaration** — Use `stubTranslation` to mark strings for scanner extraction (returns key as-is, does NOT translate):
|
||||
|
||||
```ts
|
||||
import { stubTranslation as _ } from '@/utils/misc';
|
||||
|
||||
// These calls only register keys for extraction
|
||||
_('Reveal in Finder');
|
||||
_('Reveal in Explorer');
|
||||
```
|
||||
|
||||
**2. Usage** — In the React component that consumes the value, apply the real `_()` from `useTranslation`:
|
||||
|
||||
```tsx
|
||||
const _ = useTranslation();
|
||||
const label = _(getRevealLabel()); // translates at runtime
|
||||
```
|
||||
|
||||
### Extraction & Translation
|
||||
|
||||
```bash
|
||||
pnpm i18n:extract # Scans codebase, adds new keys with __STRING_NOT_TRANSLATED__
|
||||
```
|
||||
|
||||
- Translation files: `public/locales/<locale>/translation.json`
|
||||
- Only `_('KEY')` and `_('KEY', options)` patterns are recognized by i18next-scanner
|
||||
|
||||
### Adding a New Translation Language
|
||||
|
||||
The supported language set has a single ground truth: [`i18n-langs.json`](../i18n-langs.json). Both the i18next runtime (`src/i18n/i18n.ts`) and the extractor (`i18next-scanner.config.cjs`) read from it, so adding a locale is a two-file change plus a translation pass.
|
||||
|
||||
1. **Add the locale code** to [`i18n-langs.json`](../i18n-langs.json). Use the exact code i18next will emit (e.g. `hu`, `zh-CN`). Do not add `en` — it's the source language and lives outside this list.
|
||||
|
||||
2. **Add a display label** to `TRANSLATED_LANGS` in [`src/services/constants.ts`](../src/services/constants.ts). The key is the locale code, the value is the language's native name (e.g. `hu: 'Magyar'`). This is what users see in the language picker.
|
||||
|
||||
3. **Generate the translation file**:
|
||||
|
||||
```bash
|
||||
pnpm i18n:extract
|
||||
```
|
||||
|
||||
This creates `public/locales/<code>/translation.json` with every key set to `__STRING_NOT_TRANSLATED__`.
|
||||
|
||||
4. **Translate** every `__STRING_NOT_TRANSLATED__` placeholder in the new file. The `/i18n` skill automates this; the singular `en/translation.json` only holds plural variants and proper nouns, so use the JSON keys themselves as the English source.
|
||||
|
||||
5. **Verify** with `grep -r "__STRING_NOT_TRANSLATED__" public/locales/<code>/` — the result should be empty.
|
||||
|
||||
6. **Translate the KOReader companion plugin** (`apps/readest.koplugin`). It pulls the locale set from the same `i18n-langs.json` via the scanner config, but the catalog format is gettext `.po`, not JSON. Steps:
|
||||
- Add a `LANG_META` entry (label + Plural-Forms) for the new code in [`apps/readest.koplugin/scripts/extract-i18n.js`](../../readest.koplugin/scripts/extract-i18n.js). Without it the extractor prints `<code> skipped (no metadata in extract-i18n.js)` and the catalog is never created.
|
||||
- Trigger the `/i18n-koplugin` skill to run extraction and fill every empty `msgstr ""` in `apps/readest.koplugin/locales/<code>/translation.po`.
|
||||
|
||||
### Rules
|
||||
|
||||
- `stubTranslation` is for extraction only — always apply `_()` from `useTranslation` in the component for runtime translation.
|
||||
- Fallback: when no translation exists, the English key itself is displayed.
|
||||
- Error messages: register keys with `stubTranslation` in utility modules (e.g. `src/services/errors.ts`), return the English key from helpers, wrap with `_()` in the component.
|
||||
@@ -1,54 +0,0 @@
|
||||
## Safe Area Insets
|
||||
|
||||
The app runs on devices with notches, status bars, and rounded corners (iOS, Android). UI elements near screen edges must account for safe area insets to avoid being obscured.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **`gridInsets: Insets`** — Per-view insets derived from view settings (header/footer visibility, margins). Calculated by `getViewInsets()` in `src/utils/insets.ts`. Passed as a prop from `BooksGrid` → child components.
|
||||
- **`statusBarHeight: number`** — OS status bar height (default 24px). Stored in `themeStore`.
|
||||
- **`systemUIVisible: boolean`** — Whether the system UI (status bar, navigation bar) is currently shown. Stored in `themeStore`.
|
||||
- **`appService?.hasSafeAreaInset`** — Whether the platform requires safe area handling (mobile devices).
|
||||
|
||||
### Top Inset Rules
|
||||
|
||||
For UI elements anchored to the **top** of the screen (headers, close buttons, overlays):
|
||||
|
||||
```tsx
|
||||
// When system UI is visible, use the larger of gridInsets.top and statusBarHeight
|
||||
// When system UI is hidden, use gridInsets.top alone
|
||||
style={{
|
||||
marginTop: systemUIVisible
|
||||
? `${Math.max(gridInsets.top, statusBarHeight)}px`
|
||||
: `${gridInsets.top}px`,
|
||||
}}
|
||||
```
|
||||
|
||||
For containers that need safe area padding at the top:
|
||||
|
||||
```tsx
|
||||
style={{
|
||||
paddingTop: appService?.hasSafeAreaInset ? `${gridInsets.top}px` : '0px',
|
||||
}}
|
||||
```
|
||||
|
||||
For top-anchored slide-in panels (sidebar, notebook), use `getPanelTopInset()` from `src/utils/insets.ts`. It clears the status bar on tablet/desktop and full-height mobile sheets, but stays flush for a partial-height mobile bottom sheet (which doesn't reach the top of the screen). Gating only on `isFullHeightInMobile` is wrong — a non-mobile panel is also top-anchored and would let the status bar obscure its toolbar.
|
||||
|
||||
### Bottom Inset Rules
|
||||
|
||||
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
|
||||
|
||||
```tsx
|
||||
style={{
|
||||
paddingBottom: appService?.hasSafeAreaInset ? `${gridInsets.bottom * 0.33}px` : 0,
|
||||
}}
|
||||
```
|
||||
|
||||
### Passing `gridInsets`
|
||||
|
||||
When creating overlay components (image viewers, table viewers, zoom controls, etc.), always pass `gridInsets` as a prop so they can position their controls correctly:
|
||||
|
||||
```tsx
|
||||
<ImageViewer gridInsets={gridInsets} ... />
|
||||
<TableViewer gridInsets={gridInsets} ... />
|
||||
<ZoomControls gridInsets={gridInsets} ... />
|
||||
```
|
||||
@@ -1,613 +0,0 @@
|
||||
# Line-Aware Reading Ruler Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the reading ruler snap to the next group of *actual* rendered text lines on each tap/page-change so lines stay centered in the band, eliminating the drift caused by the current arithmetic step.
|
||||
|
||||
**Architecture:** Two new pure functions in `src/app/reader/utils/readingRuler.ts` derive line boxes from `progress.range.getClientRects()` and compute a snapped band center. `ReadingRuler.tsx` caches the line boxes per page and calls the snap function from both the tap handler and the page-change auto-move, falling back to the existing arithmetic step when line geometry is unavailable (scrolled mode, fixed-layout, missing range).
|
||||
|
||||
**Tech Stack:** TypeScript, React, Vitest, foliate-js paginator (`progress.range`).
|
||||
|
||||
**Design spec:** `docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `src/app/reader/utils/readingRuler.ts` (modify) — add `ReadingRulerLineBox` type, `READING_RULER_LINE_PADDING_PX`, `buildLineBoxes`, `snapReadingRulerToLines`. Pure, no DOM.
|
||||
- `src/__tests__/utils/readingRuler.test.ts` (modify) — unit tests for the two new functions.
|
||||
- `src/app/reader/components/ReadingRuler.tsx` (modify) — glue: padded band size, per-page line-box cache, snap in tap handler + page-change auto-move, fallbacks.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `ReadingRulerLineBox` type, padding constant, and `buildLineBoxes`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/utils/readingRuler.ts`
|
||||
- Test: `src/__tests__/utils/readingRuler.test.ts`
|
||||
|
||||
`buildLineBoxes` converts per-fragment client rects (from `range.getClientRects()`) into sorted visual-line spans along the ruler axis, in container coordinates. It clusters fragments that belong to the same visual line (high overlap on the ruler axis) and maps coordinates exactly as the existing auto-move does:
|
||||
- horizontal: span = `[rect.top - containerRect.top, rect.bottom - containerRect.top]`
|
||||
- vertical-rl (`rtl=true`): span = `[containerRect.right - rect.right, containerRect.right - rect.left]`
|
||||
- vertical-lr (`rtl=false`): span = `[rect.left - containerRect.left, rect.right - containerRect.left]`
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Add these imports and tests to `src/__tests__/utils/readingRuler.test.ts`. Update the existing top import block to also import the new symbols:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
buildLineBoxes,
|
||||
calculateReadingRulerSize,
|
||||
clampReadingRulerPosition,
|
||||
FIXED_LAYOUT_READING_RULER_LINE_HEIGHT,
|
||||
getReadingRulerMoveDirection,
|
||||
READING_RULER_LINE_PADDING_PX,
|
||||
snapReadingRulerToLines,
|
||||
stepReadingRulerPosition,
|
||||
} from '@/app/reader/utils/readingRuler';
|
||||
```
|
||||
|
||||
Then add a new `describe` block at the end of the file:
|
||||
|
||||
```typescript
|
||||
type RectLike = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const rect = (top: number, left: number, height: number, width: number): RectLike => ({
|
||||
top,
|
||||
left,
|
||||
bottom: top + height,
|
||||
right: left + width,
|
||||
height,
|
||||
width,
|
||||
});
|
||||
|
||||
const container = { top: 0, left: 0, right: 300, bottom: 400 };
|
||||
|
||||
describe('buildLineBoxes', () => {
|
||||
it('clusters horizontal fragments into one box per visual line', () => {
|
||||
const rects = [
|
||||
rect(0, 10, 16, 50), // line 1, fragment A
|
||||
rect(0, 60, 16, 40), // line 1, fragment B (same vertical band)
|
||||
rect(20, 10, 16, 80), // line 2
|
||||
];
|
||||
expect(buildLineBoxes(rects, false, false, container)).toEqual([
|
||||
{ start: 0, end: 16 },
|
||||
{ start: 20, end: 36 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores zero-size rects', () => {
|
||||
const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)];
|
||||
expect(buildLineBoxes(rects, false, false, container)).toEqual([
|
||||
{ start: 0, end: 16 },
|
||||
{ start: 40, end: 56 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns sorted boxes even when rects are out of order', () => {
|
||||
const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)];
|
||||
expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]);
|
||||
});
|
||||
|
||||
it('maps vertical-rl columns as distance from the right edge', () => {
|
||||
// container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40]
|
||||
const rects = [rect(0, 260, 200, 16)];
|
||||
expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]);
|
||||
});
|
||||
|
||||
it('maps vertical-lr columns as distance from the left edge', () => {
|
||||
const rects = [rect(0, 24, 200, 16)];
|
||||
expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
|
||||
Expected: FAIL — `buildLineBoxes`, `READING_RULER_LINE_PADDING_PX`, `snapReadingRulerToLines` are not exported (also a build/type error on the import).
|
||||
|
||||
- [ ] **Step 3: Implement the type, constant, and `buildLineBoxes`**
|
||||
|
||||
In `src/app/reader/utils/readingRuler.ts`, add below the existing `FIXED_LAYOUT_READING_RULER_LINE_HEIGHT` constant (line 3):
|
||||
|
||||
```typescript
|
||||
// Extra band height (px) added so the centered lines clear the band edges.
|
||||
export const READING_RULER_LINE_PADDING_PX = 6;
|
||||
|
||||
export interface ReadingRulerLineBox {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RulerRect = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type RulerContainerRect = { top: number; left: number; right: number };
|
||||
|
||||
/**
|
||||
* Convert per-fragment client rects into sorted visual-line spans along the
|
||||
* ruler axis, in container coordinates. Fragments that overlap by more than
|
||||
* half of the smaller fragment on the ruler axis are treated as one line.
|
||||
*/
|
||||
export const buildLineBoxes = (
|
||||
rects: RulerRect[],
|
||||
isVertical: boolean,
|
||||
rtl: boolean,
|
||||
containerRect: RulerContainerRect,
|
||||
): ReadingRulerLineBox[] => {
|
||||
const spans: ReadingRulerLineBox[] = [];
|
||||
for (const r of rects) {
|
||||
if (!r || r.width <= 0 || r.height <= 0) continue;
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (isVertical) {
|
||||
if (rtl) {
|
||||
start = containerRect.right - r.right;
|
||||
end = containerRect.right - r.left;
|
||||
} else {
|
||||
start = r.left - containerRect.left;
|
||||
end = r.right - containerRect.left;
|
||||
}
|
||||
} else {
|
||||
start = r.top - containerRect.top;
|
||||
end = r.bottom - containerRect.top;
|
||||
}
|
||||
if (end < start) [start, end] = [end, start];
|
||||
spans.push({ start, end });
|
||||
}
|
||||
|
||||
spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
||||
|
||||
const lines: ReadingRulerLineBox[] = [];
|
||||
for (const span of spans) {
|
||||
const current = lines[lines.length - 1];
|
||||
if (current) {
|
||||
const overlap = Math.min(current.end, span.end) - Math.max(current.start, span.start);
|
||||
const minHeight = Math.min(current.end - current.start, span.end - span.start);
|
||||
if (overlap > 0.5 * minHeight) {
|
||||
current.start = Math.min(current.start, span.start);
|
||||
current.end = Math.max(current.end, span.end);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
lines.push({ start: span.start, end: span.end });
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify the `buildLineBoxes` tests pass**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
|
||||
Expected: the `buildLineBoxes` describe block PASSES. (The `snapReadingRulerToLines` import still makes the file fail to compile — that's fixed in Task 2. If the runner refuses to run due to the missing export, temporarily comment out the `snapReadingRulerToLines` import line to confirm, then restore it.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
|
||||
git commit -m "feat(ruler): add buildLineBoxes for line-aware ruler geometry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `snapReadingRulerToLines`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/utils/readingRuler.ts`
|
||||
- Test: `src/__tests__/utils/readingRuler.test.ts`
|
||||
|
||||
Given the current band center, viewport dimension, padded band size, line count, direction, and the line boxes, return the next band center (px) centered on the next `lines`-line block — or `null` when there is no next group (caller falls back to a page flip).
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `src/__tests__/utils/readingRuler.test.ts`:
|
||||
|
||||
```typescript
|
||||
describe('snapReadingRulerToLines', () => {
|
||||
// 10 lines, each 16px tall, 20px apart, starting at 0.
|
||||
const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({
|
||||
start: s,
|
||||
end: s + 16,
|
||||
}));
|
||||
|
||||
it('returns null when there are no line boxes', () => {
|
||||
expect(snapReadingRulerToLines(100, 400, 40, 2, 'forward', [])).toBeNull();
|
||||
});
|
||||
|
||||
it('advances forward to the next block of N lines, centered on the block', () => {
|
||||
// band center 20 -> band [0,40]; next group starts at line index 2 (start 40),
|
||||
// block = lines[2..3] => [40, 76], center = 58.
|
||||
expect(snapReadingRulerToLines(20, 400, 40, 2, 'forward', evenBoxes)).toBe(58);
|
||||
});
|
||||
|
||||
it('moves backward to the previous block of N lines, centered on the block', () => {
|
||||
const boxes = [40, 60, 80, 100, 120, 140, 160, 180, 200, 220].map((s) => ({
|
||||
start: s,
|
||||
end: s + 16,
|
||||
}));
|
||||
// band center 200 -> band [180,220]; last line fully above is index 6 (end 176),
|
||||
// block = lines[5..6] => [140, 176], center = 158.
|
||||
expect(snapReadingRulerToLines(200, 400, 40, 2, 'backward', boxes)).toBe(158);
|
||||
});
|
||||
|
||||
it('returns null at the bottom boundary so the page can flip', () => {
|
||||
const boxes = [
|
||||
{ start: 0, end: 16 },
|
||||
{ start: 20, end: 36 },
|
||||
];
|
||||
// band center 100 -> band [80,120]; no line starts below -> null.
|
||||
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null at the top boundary so the page can flip', () => {
|
||||
const boxes = [
|
||||
{ start: 80, end: 96 },
|
||||
{ start: 100, end: 116 },
|
||||
];
|
||||
// band center 100 -> band [80,120]; no line ends above -> null.
|
||||
expect(snapReadingRulerToLines(100, 200, 40, 2, 'backward', boxes)).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps the snapped center so the band stays inside the viewport', () => {
|
||||
const boxes = [{ start: 180, end: 196 }];
|
||||
// forward target block center = 188, but dimension 200 / size 40 clamps to 180.
|
||||
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBe(180);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
|
||||
Expected: FAIL — `snapReadingRulerToLines is not a function` (export missing).
|
||||
|
||||
- [ ] **Step 3: Implement `snapReadingRulerToLines`**
|
||||
|
||||
In `src/app/reader/utils/readingRuler.ts`, add after `stepReadingRulerPosition`:
|
||||
|
||||
```typescript
|
||||
const clampCenterPx = (center: number, dimension: number, rulerSize: number): number => {
|
||||
const half = rulerSize / 2;
|
||||
if (half * 2 >= dimension) return dimension / 2;
|
||||
return Math.max(half, Math.min(dimension - half, center));
|
||||
};
|
||||
|
||||
/**
|
||||
* Snap the ruler band to the next/previous block of `lines` real text lines,
|
||||
* centered on that block. Returns the new band center in px, or null when there
|
||||
* is no next group in the given direction (the caller then flips the page).
|
||||
*/
|
||||
export const snapReadingRulerToLines = (
|
||||
currentCenterPx: number,
|
||||
dimension: number,
|
||||
rulerSize: number,
|
||||
lines: number,
|
||||
direction: 'backward' | 'forward',
|
||||
lineBoxes: ReadingRulerLineBox[],
|
||||
): number | null => {
|
||||
if (lineBoxes.length === 0 || dimension <= 0) return null;
|
||||
|
||||
const count = Math.max(1, Math.floor(lines));
|
||||
const heights = lineBoxes
|
||||
.map((b) => b.end - b.start)
|
||||
.filter((h) => h > 0)
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights.length ? heights[Math.floor(heights.length / 2)] : 0;
|
||||
const eps = medianHeight * 0.3;
|
||||
|
||||
const half = rulerSize / 2;
|
||||
const bandStart = currentCenterPx - half;
|
||||
const bandEnd = currentCenterPx + half;
|
||||
|
||||
if (direction === 'forward') {
|
||||
const startIdx = lineBoxes.findIndex((b) => b.start >= bandEnd - eps);
|
||||
if (startIdx === -1) return null;
|
||||
const endIdx = Math.min(startIdx + count - 1, lineBoxes.length - 1);
|
||||
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
|
||||
return clampCenterPx(center, dimension, rulerSize);
|
||||
}
|
||||
|
||||
let endIdx = -1;
|
||||
for (let i = lineBoxes.length - 1; i >= 0; i--) {
|
||||
if (lineBoxes[i].end <= bandStart + eps) {
|
||||
endIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (endIdx === -1) return null;
|
||||
const startIdx = Math.max(endIdx - count + 1, 0);
|
||||
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
|
||||
return clampCenterPx(center, dimension, rulerSize);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
|
||||
Expected: PASS (all describe blocks, including the original ones).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
|
||||
git commit -m "feat(ruler): add snapReadingRulerToLines for line-aware stepping"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Wire line-aware snapping into `ReadingRuler.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/components/ReadingRuler.tsx`
|
||||
|
||||
Glue the pure functions in: padded band size for snap-capable books, a per-page line-box cache, and snapping in both the tap handler and the page-change auto-move, with the existing arithmetic path as fallback.
|
||||
|
||||
This task is verified by `pnpm lint` + `pnpm test` (the pure logic is fully covered by Tasks 1–2) and a manual check in the reader, since the behavior depends on live DOM range geometry that unit tests cannot reproduce.
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
In `src/app/reader/components/ReadingRuler.tsx`, change the `@/types/book` import (line 4) from:
|
||||
|
||||
```typescript
|
||||
import { BookFormat, ViewSettings } from '@/types/book';
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book';
|
||||
```
|
||||
|
||||
And change the `../utils/readingRuler` import (lines 12-16) from:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
calculateReadingRulerSize,
|
||||
clampReadingRulerPosition,
|
||||
stepReadingRulerPosition,
|
||||
} from '../utils/readingRuler';
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
buildLineBoxes,
|
||||
calculateReadingRulerSize,
|
||||
clampReadingRulerPosition,
|
||||
READING_RULER_LINE_PADDING_PX,
|
||||
ReadingRulerLineBox,
|
||||
snapReadingRulerToLines,
|
||||
stepReadingRulerPosition,
|
||||
} from '../utils/readingRuler';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Compute `supportsLineSnap` and padded `rulerSize`**
|
||||
|
||||
Replace line 61:
|
||||
|
||||
```typescript
|
||||
const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```typescript
|
||||
const supportsLineSnap = !viewSettings.scrolled && !FIXED_LAYOUT_FORMATS.has(bookFormat);
|
||||
const baseRulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
|
||||
const rulerSize = baseRulerSize + (supportsLineSnap ? READING_RULER_LINE_PADDING_PX : 0);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the line-box cache ref**
|
||||
|
||||
After the `currentPositionRef` declaration (line 59), add:
|
||||
|
||||
```typescript
|
||||
const lineBoxesRef = useRef<ReadingRulerLineBox[]>([]);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Keep the line-box cache in sync per page**
|
||||
|
||||
Immediately after the container-size effect (the `useEffect` that ends at line 118, returning `() => resizeObserver.disconnect()`), add a new effect:
|
||||
|
||||
```typescript
|
||||
// Cache the visible line boxes for the current page so taps can snap to real lines.
|
||||
useEffect(() => {
|
||||
if (!supportsLineSnap) {
|
||||
lineBoxesRef.current = [];
|
||||
return;
|
||||
}
|
||||
const range = progress?.range ?? null;
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!range || !containerRect) {
|
||||
lineBoxesRef.current = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rects = Array.from(range.getClientRects());
|
||||
lineBoxesRef.current = buildLineBoxes(rects, isVertical, rtl, containerRect);
|
||||
} catch {
|
||||
lineBoxesRef.current = [];
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
progress?.range,
|
||||
progress?.pageinfo?.current,
|
||||
containerSize.width,
|
||||
containerSize.height,
|
||||
isVertical,
|
||||
rtl,
|
||||
supportsLineSnap,
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Snap on page-change auto-move**
|
||||
|
||||
Replace the `performAutoMove` function body (lines 193-215) with the version below. It tries the line snap first and keeps the existing first-visible-text offset as a fallback:
|
||||
|
||||
```typescript
|
||||
const performAutoMove = (range: Range | null) => {
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!containerRect) return;
|
||||
|
||||
const containerDimension = isVertical ? containerRect.width : containerRect.height;
|
||||
if (containerDimension <= 0) return;
|
||||
|
||||
if (supportsLineSnap && range) {
|
||||
try {
|
||||
const rects = Array.from(range.getClientRects());
|
||||
const boxes = buildLineBoxes(rects, isVertical, rtl, containerRect);
|
||||
lineBoxesRef.current = boxes;
|
||||
// Align to the first line group from the top of the page.
|
||||
const snapped = snapReadingRulerToLines(
|
||||
-rulerSize,
|
||||
containerDimension,
|
||||
rulerSize,
|
||||
lines,
|
||||
'forward',
|
||||
boxes,
|
||||
);
|
||||
if (snapped != null) {
|
||||
setRulerPosition((snapped / containerDimension) * 100, true);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to default offset */
|
||||
}
|
||||
}
|
||||
|
||||
const textPosition = getFirstVisibleTextPosition(range);
|
||||
// For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr
|
||||
const defaultOffset = isVertical
|
||||
? rtl
|
||||
? (viewSettings.marginRightPx ?? 44)
|
||||
: (viewSettings.marginLeftPx ?? 44)
|
||||
: (viewSettings.marginTopPx ?? 44);
|
||||
|
||||
const offset = textPosition ?? defaultOffset;
|
||||
const targetPosition = clampPosition(
|
||||
((offset + rulerSize / 2) / containerDimension) * 100,
|
||||
containerDimension,
|
||||
);
|
||||
|
||||
setRulerPosition(targetPosition, true);
|
||||
};
|
||||
```
|
||||
|
||||
Then add `lines` and `supportsLineSnap` to the auto-move effect's dependency array (the array currently ending at lines 232-242). It should read:
|
||||
|
||||
```typescript
|
||||
}, [
|
||||
progress?.pageinfo?.current,
|
||||
viewSettings.scrolled,
|
||||
isVertical,
|
||||
rtl,
|
||||
viewSettings.marginTopPx,
|
||||
viewSettings.marginLeftPx,
|
||||
viewSettings.marginRightPx,
|
||||
rulerSize,
|
||||
lines,
|
||||
supportsLineSnap,
|
||||
setRulerPosition,
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Snap in the tap/key move handler**
|
||||
|
||||
In the `reading-ruler-move` effect, replace the body from the `const nextPosition = stepReadingRulerPosition(...)` block through the `return true;` (lines 400-412) with:
|
||||
|
||||
```typescript
|
||||
let nextPosition: number;
|
||||
if (supportsLineSnap && lineBoxesRef.current.length > 0) {
|
||||
const currentCenterPx = (currentPositionRef.current / 100) * dimension;
|
||||
const snapped = snapReadingRulerToLines(
|
||||
currentCenterPx,
|
||||
dimension,
|
||||
rulerSize,
|
||||
lines,
|
||||
detail.direction,
|
||||
lineBoxesRef.current,
|
||||
);
|
||||
// No next line group in this direction: let the page flip instead.
|
||||
if (snapped == null) return false;
|
||||
nextPosition = (snapped / dimension) * 100;
|
||||
} else {
|
||||
nextPosition = stepReadingRulerPosition(
|
||||
currentPositionRef.current,
|
||||
dimension,
|
||||
rulerSize,
|
||||
detail.direction,
|
||||
);
|
||||
}
|
||||
|
||||
if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) {
|
||||
return false;
|
||||
}
|
||||
|
||||
setRulerPosition(nextPosition, true);
|
||||
return true;
|
||||
```
|
||||
|
||||
Then add `lines` and `supportsLineSnap` to that effect's dependency array (currently line 419) so it reads:
|
||||
|
||||
```typescript
|
||||
}, [
|
||||
bookKey,
|
||||
containerSize.height,
|
||||
containerSize.width,
|
||||
isVertical,
|
||||
lines,
|
||||
rulerSize,
|
||||
supportsLineSnap,
|
||||
setRulerPosition,
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Type-check and lint**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS (no Biome errors, no tsgo type errors). If tsgo complains that `ReadingRulerLineBox` is unused, confirm Step 3 added the `lineBoxesRef` typed with it.
|
||||
|
||||
- [ ] **Step 8: Run the full unit suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: PASS — no regressions.
|
||||
|
||||
- [ ] **Step 9: Manual verification in the reader**
|
||||
|
||||
Start the web dev server (`pnpm dev-web`), open a reflowable EPUB, enable the reading ruler (Settings → Color/Layout → Reading Ruler), and confirm:
|
||||
- Tapping the page advances the band so the next lines sit centered in it, with no manual adjustment needed across many taps.
|
||||
- At the bottom of a page, a forward tap flips the page and the band lands centered on the first lines of the new page.
|
||||
- Backward taps and (if available) a vertical-writing-mode book behave symmetrically.
|
||||
- A fixed-layout PDF still uses the old fixed-step behavior (no errors).
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/readest-app/src/app/reader/components/ReadingRuler.tsx
|
||||
git commit -m "feat(ruler): snap reading ruler to real text lines on tap and page change"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- **Spec coverage:** snapping rule (Tasks 1–2), fixed-size + padding (`READING_RULER_LINE_PADDING_PX`, Task 3 Step 2), reflowable + vertical scope (`buildLineBoxes` mapping + `supportsLineSnap`), fallback matrix (scrolled/fixed-layout/missing range/boundary all route to `stepReadingRulerPosition` or page flip), per-page caching (Task 3 Step 4). All covered.
|
||||
- **Type consistency:** `ReadingRulerLineBox { start; end }`, `buildLineBoxes(rects, isVertical, rtl, containerRect)`, and `snapReadingRulerToLines(currentCenterPx, dimension, rulerSize, lines, direction, lineBoxes)` are used identically in tests and glue.
|
||||
- **No placeholders:** every code step contains complete code and exact run commands.
|
||||
@@ -1,557 +0,0 @@
|
||||
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a keyboard action (default `Shift+M`, "Text to Speech" section) that persists the sentence TTS is currently reading aloud as a normal highlight using the user's default style/color — eyes-off, silent, idempotent (skip duplicates).
|
||||
|
||||
**Architecture:** Connect the two existing owners through the app event bus. `TTSController` is the only place that knows both `view.tts` (current sentence Range) and the TTS section index, so it exposes `getSpokenSentence(): { cfi, text } | null`. `Annotator` owns highlight persistence/rendering, so it creates the note. The shortcut → `useBookShortcuts` dispatches `tts-highlight-sentence` → `useTTSControl` (holds the controller ref) resolves the sentence and dispatches `create-tts-highlight` → `Annotator` builds/persists/draws the highlight. The bug-prone create-or-skip decision is a pure, unit-tested helper.
|
||||
|
||||
**Tech Stack:** TypeScript, React, Zustand, foliate-js, Vitest. Spec: `docs/superpowers/specs/2026-05-30-tts-highlight-current-sentence-design.md`.
|
||||
|
||||
**Conventions:**
|
||||
- Test-first (project rule `.agents/rules/test-first.md`): write the failing test, run it red, implement, run it green.
|
||||
- Never use the `any` type (`.agents/rules/typescript.md`); the test code below casts mock objects via `as unknown as <Type>`, matching the existing suites.
|
||||
- Run a single test file with `pnpm test <path>` (no `--`).
|
||||
- Conventional commits with scope, e.g. `feat(tts): ...`. End every commit message body with:
|
||||
```
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
- Do not push during implementation; commit locally only.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| File | Change | Responsibility |
|
||||
| ---- | ------ | -------------- |
|
||||
| `src/app/reader/utils/annotatorUtil.ts` | Modify | Add pure `buildTTSSentenceHighlight()` (create-or-skip decision + BookNote assembly) |
|
||||
| `src/__tests__/utils/annotator-util.test.ts` | Modify | Unit tests for `buildTTSSentenceHighlight` |
|
||||
| `src/services/tts/TTSController.ts` | Modify | Add `getSpokenSentence()` resolver |
|
||||
| `src/__tests__/services/tts-controller.test.ts` | Modify | Unit tests for `getSpokenSentence` |
|
||||
| `src/helpers/shortcuts.ts` | Modify | Register `onTTSHighlightSentence` default binding |
|
||||
| `src/app/reader/hooks/useBookShortcuts.ts` | Modify | Handler that dispatches `tts-highlight-sentence` |
|
||||
| `src/app/reader/hooks/useTTSControl.ts` | Modify | Resolve sentence via controller, relay `create-tts-highlight` |
|
||||
| `src/app/reader/components/annotator/Annotator.tsx` | Modify | Handle `create-tts-highlight`: build/persist/draw |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Pure helper `buildTTSSentenceHighlight`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/utils/annotatorUtil.ts`
|
||||
- Test: `src/__tests__/utils/annotator-util.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append to `src/__tests__/utils/annotator-util.test.ts`. Also add `buildTTSSentenceHighlight` to the existing import from `@/app/reader/utils/annotatorUtil` and `HighlightStyle`, `HighlightColor` to the existing `@/types/book` import:
|
||||
|
||||
```ts
|
||||
describe('buildTTSSentenceHighlight', () => {
|
||||
const params = {
|
||||
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
|
||||
text: 'A spoken sentence.',
|
||||
style: 'highlight' as HighlightStyle,
|
||||
color: 'yellow' as HighlightColor,
|
||||
page: 7,
|
||||
};
|
||||
|
||||
it('builds an annotation BookNote when none exists at the cfi', () => {
|
||||
const note = buildTTSSentenceHighlight([], params, 1000);
|
||||
expect(note).not.toBeNull();
|
||||
expect(note).toMatchObject({
|
||||
type: 'annotation',
|
||||
cfi: params.cfi,
|
||||
text: params.text,
|
||||
style: 'highlight',
|
||||
color: 'yellow',
|
||||
page: 7,
|
||||
note: '',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
});
|
||||
expect(typeof note!.id).toBe('string');
|
||||
expect(note!.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns null (skip) when a live annotation already exists at the cfi', () => {
|
||||
const existing: BookNote = {
|
||||
id: 'a1',
|
||||
type: 'annotation',
|
||||
cfi: params.cfi,
|
||||
style: 'highlight',
|
||||
color: 'red',
|
||||
text: params.text,
|
||||
note: '',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
expect(buildTTSSentenceHighlight([existing], params, 1000)).toBeNull();
|
||||
});
|
||||
|
||||
it('builds when the only note at the cfi is soft-deleted', () => {
|
||||
const deleted: BookNote = {
|
||||
id: 'a1',
|
||||
type: 'annotation',
|
||||
cfi: params.cfi,
|
||||
style: 'highlight',
|
||||
color: 'red',
|
||||
text: params.text,
|
||||
note: '',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
deletedAt: 5,
|
||||
};
|
||||
expect(buildTTSSentenceHighlight([deleted], params, 1000)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('builds when the note at the cfi is a non-annotation (bookmark)', () => {
|
||||
const bookmark: BookNote = {
|
||||
id: 'b1',
|
||||
type: 'bookmark',
|
||||
cfi: params.cfi,
|
||||
note: '',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
expect(buildTTSSentenceHighlight([bookmark], params, 1000)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
|
||||
Expected: FAIL — `buildTTSSentenceHighlight is not a function` / import error.
|
||||
|
||||
- [ ] **Step 3: Implement the helper**
|
||||
|
||||
In `src/app/reader/utils/annotatorUtil.ts`, extend the top imports and add the function. Change the `@/types/book` import line to include `HighlightStyle`, and add `uniqueId`:
|
||||
|
||||
```ts
|
||||
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
|
||||
import { uniqueId } from '@/utils/misc';
|
||||
```
|
||||
|
||||
Add at the end of the file:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
|
||||
* `null` when one already exists at the same CFI (idempotent — pressing the
|
||||
* hotkey twice on the same sentence must not create a duplicate).
|
||||
*
|
||||
* `now` is injected so the result is deterministic for tests. A soft-deleted
|
||||
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
|
||||
* does not block creation — it mirrors the live-annotation predicate used by
|
||||
* the selection-based highlight path in Annotator.tsx.
|
||||
*/
|
||||
export function buildTTSSentenceHighlight(
|
||||
annotations: BookNote[],
|
||||
params: {
|
||||
cfi: string;
|
||||
text: string;
|
||||
style: HighlightStyle;
|
||||
color: HighlightColor;
|
||||
page?: number;
|
||||
},
|
||||
now: number,
|
||||
): BookNote | null {
|
||||
const exists = annotations.some(
|
||||
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
|
||||
);
|
||||
if (exists) return null;
|
||||
return {
|
||||
id: uniqueId(),
|
||||
type: 'annotation',
|
||||
note: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
|
||||
Expected: PASS (all `buildTTSSentenceHighlight` cases green, existing cases still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/reader/utils/annotatorUtil.ts src/__tests__/utils/annotator-util.test.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): add buildTTSSentenceHighlight helper for sentence highlights
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `TTSController.getSpokenSentence()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/tts/TTSController.ts` (add method near `dispatchSpeakMark`, ~line 565)
|
||||
- Test: `src/__tests__/services/tts-controller.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Append a new `describe` block inside the top-level `describe('TTSController', ...)` in `src/__tests__/services/tts-controller.test.ts` (e.g. after the `dispatchSpeakMark` block). It reuses the file's existing `controller`, `mockView`, and `createMockView` setup:
|
||||
|
||||
```ts
|
||||
describe('getSpokenSentence', () => {
|
||||
test('returns the trimmed text and cfi of the current sentence', async () => {
|
||||
await controller.initViewTTS(0);
|
||||
mockView.tts = {
|
||||
getLastRange: vi.fn().mockReturnValue({ toString: () => ' A spoken sentence. ' }),
|
||||
} as unknown as FoliateView['tts'];
|
||||
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
|
||||
|
||||
expect(controller.getSpokenSentence()).toEqual({
|
||||
cfi: 'cfi-current',
|
||||
text: 'A spoken sentence.',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null when TTS is inactive (no view.tts)', () => {
|
||||
// No initViewTTS: view.tts is null and the section index is -1.
|
||||
expect(controller.getSpokenSentence()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when there is no current range', async () => {
|
||||
await controller.initViewTTS(0);
|
||||
mockView.tts = {
|
||||
getLastRange: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as FoliateView['tts'];
|
||||
|
||||
expect(controller.getSpokenSentence()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when getCFI throws', async () => {
|
||||
await controller.initViewTTS(0);
|
||||
mockView.tts = {
|
||||
getLastRange: vi.fn().mockReturnValue({ toString: () => 'x' }),
|
||||
} as unknown as FoliateView['tts'];
|
||||
vi.mocked(mockView.getCFI).mockImplementation(() => {
|
||||
throw new Error('cfi failure');
|
||||
});
|
||||
|
||||
expect(controller.getSpokenSentence()).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null when the sentence text is only whitespace', async () => {
|
||||
await controller.initViewTTS(0);
|
||||
mockView.tts = {
|
||||
getLastRange: vi.fn().mockReturnValue({ toString: () => ' ' }),
|
||||
} as unknown as FoliateView['tts'];
|
||||
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
|
||||
|
||||
expect(controller.getSpokenSentence()).toBeNull();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
|
||||
Expected: FAIL — `controller.getSpokenSentence is not a function`.
|
||||
|
||||
- [ ] **Step 3: Implement the method**
|
||||
|
||||
In `src/services/tts/TTSController.ts`, add this public method immediately above `dispatchSpeakMark(mark?: TTSMark)` (~line 565). It performs the same Range→CFI conversion `dispatchSpeakMark` already uses, reading the current sentence Range from the foliate TTS engine and the active TTS section index:
|
||||
|
||||
```ts
|
||||
getSpokenSentence(): { cfi: string; text: string } | null {
|
||||
const range = this.view.tts?.getLastRange();
|
||||
if (!range || this.#ttsSectionIndex < 0) return null;
|
||||
try {
|
||||
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
|
||||
const text = range.toString().trim();
|
||||
if (!cfi || !text) return null;
|
||||
return { cfi, text };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
|
||||
Expected: PASS (new cases green, existing cases still green).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/services/tts/TTSController.ts src/__tests__/services/tts-controller.test.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): expose getSpokenSentence on TTSController
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Register the default shortcut binding
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/helpers/shortcuts.ts` (TTS section, after `onTTSGoPreviousParagraph`, ~line 75)
|
||||
|
||||
There is no standalone unit test for the static registry; correctness is verified by `pnpm lint` (tsgo derives `ShortcutConfig` from this object) and by the wiring tasks. The new action automatically appears in the keyboard-shortcuts help dialog because its `section` is non-empty.
|
||||
|
||||
- [ ] **Step 1: Add the entry**
|
||||
|
||||
In `src/helpers/shortcuts.ts`, insert into `DEFAULT_SHORTCUTS` immediately after the `onTTSGoPreviousParagraph` block (line 75):
|
||||
|
||||
```ts
|
||||
onTTSHighlightSentence: {
|
||||
keys: ['shift+m'],
|
||||
description: _('Highlight Current Sentence'),
|
||||
section: 'Text to Speech',
|
||||
},
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Type-check**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS. `ShortcutConfig` now includes `onTTSHighlightSentence`. (If `useBookShortcuts` is type-checked before Task 4 wires the handler, `useShortcuts` accepts a partial map, so this should not error on its own; if it does, proceed to Task 4 and re-run.)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/helpers/shortcuts.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): add default Shift+M binding for highlight-current-sentence
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Dispatch the shortcut event from `useBookShortcuts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/hooks/useBookShortcuts.ts` (handler ~after line 301; registration ~line 358)
|
||||
|
||||
- [ ] **Step 1: Add the handler**
|
||||
|
||||
In `src/app/reader/hooks/useBookShortcuts.ts`, add immediately after `ttsGoPreviousParagraph` (line 301), mirroring `ttsGoNextSentence`:
|
||||
|
||||
```ts
|
||||
const ttsHighlightSentence = () => {
|
||||
if (!sideBarBookKey) return;
|
||||
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the handler**
|
||||
|
||||
In the `useShortcuts({ ... })` map, add after the `onTTSGoPreviousParagraph: ttsGoPreviousParagraph,` line (line 358):
|
||||
|
||||
```ts
|
||||
onTTSHighlightSentence: ttsHighlightSentence,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/reader/hooks/useBookShortcuts.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): dispatch tts-highlight-sentence from the shortcut handler
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Resolve the sentence and relay it from `useTTSControl`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/hooks/useTTSControl.ts` (handler ~after line 81; effect registration lines 103-114)
|
||||
|
||||
- [ ] **Step 1: Add the handler**
|
||||
|
||||
In `src/app/reader/hooks/useTTSControl.ts`, add after `handleTTSBackward` (line 81), mirroring the bookKey-matched pattern of the existing TTS handlers:
|
||||
|
||||
```ts
|
||||
const handleTTSHighlightSentence = (event: CustomEvent) => {
|
||||
const detail = event.detail as { bookKey: string } | undefined;
|
||||
if (detail?.bookKey !== bookKey) return;
|
||||
const sentence = ttsControllerRef.current?.getSpokenSentence();
|
||||
if (!sentence) return;
|
||||
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register/unregister in the existing effect**
|
||||
|
||||
In the `useEffect` at lines 103-119, add the `on`/`off` pair alongside the other TTS listeners:
|
||||
|
||||
```ts
|
||||
eventDispatcher.on('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.on('tts-stop', handleTTSStop);
|
||||
eventDispatcher.on('tts-forward', handleTTSForward);
|
||||
eventDispatcher.on('tts-backward', handleTTSBackward);
|
||||
eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay);
|
||||
eventDispatcher.on('tts-highlight-sentence', handleTTSHighlightSentence);
|
||||
return () => {
|
||||
eventDispatcher.off('tts-speak', handleTTSSpeak);
|
||||
eventDispatcher.off('tts-stop', handleTTSStop);
|
||||
eventDispatcher.off('tts-forward', handleTTSForward);
|
||||
eventDispatcher.off('tts-backward', handleTTSBackward);
|
||||
eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay);
|
||||
eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence);
|
||||
```
|
||||
|
||||
(Leave the existing `ttsControllerRef.current?.shutdown()` cleanup below unchanged.)
|
||||
|
||||
- [ ] **Step 3: Type-check**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/reader/hooks/useTTSControl.ts
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): resolve spoken sentence and relay create-tts-highlight
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create the highlight in `Annotator`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/reader/components/annotator/Annotator.tsx` (import line 50; handler near `handleHighlight` ~line 840; effect lines 535-545)
|
||||
|
||||
- [ ] **Step 1: Import the helper**
|
||||
|
||||
Change line 50 to add `buildTTSSentenceHighlight`:
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildTTSSentenceHighlight,
|
||||
getHighlightColorHex,
|
||||
removeBookNoteOverlays,
|
||||
} from '../../utils/annotatorUtil';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the event handler**
|
||||
|
||||
Add immediately after `handleHighlight` (after its closing brace, ~line 840). It reads state freshly via store getters (the listener is registered with `[]` deps, so it must not close over render-time `config`/`settings`/`progress`), matching the fresh-read pattern in `onShowAnnotation`:
|
||||
|
||||
```ts
|
||||
const handleCreateTTSHighlight = (event: CustomEvent) => {
|
||||
const detail = event.detail as { bookKey: string; cfi: string; text: string } | undefined;
|
||||
if (!detail || detail.bookKey !== bookKey) return;
|
||||
const { settings } = useSettingsStore.getState();
|
||||
const style = settings.globalReadSettings.highlightStyle;
|
||||
const color = settings.globalReadSettings.highlightStyles[style];
|
||||
const { booknotes: annotations = [] } = getConfig(bookKey)!;
|
||||
const page = getProgress(bookKey)?.page;
|
||||
const annotation = buildTTSSentenceHighlight(
|
||||
annotations,
|
||||
{ cfi: detail.cfi, text: detail.text, style, color, page },
|
||||
Date.now(),
|
||||
);
|
||||
if (!annotation) return;
|
||||
annotations.push(annotation);
|
||||
const updatedConfig = updateBooknotes(bookKey, annotations);
|
||||
if (updatedConfig) {
|
||||
saveConfig(envConfig, bookKey, updatedConfig, settings);
|
||||
}
|
||||
const views = getViewsById(bookKey.split('-')[0]!);
|
||||
views.forEach((view) => view?.addAnnotation(annotation));
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register/unregister in the existing mount effect**
|
||||
|
||||
In the `useEffect` at lines 535-545, add the `on`/`off` pair:
|
||||
|
||||
```ts
|
||||
eventDispatcher.on('export-annotations', handleExportMarkdown);
|
||||
eventDispatcher.on('clear-annotations', handleClearAnnotations);
|
||||
eventDispatcher.on('import-annotations', handleImportAnnotations);
|
||||
eventDispatcher.on('create-tts-highlight', handleCreateTTSHighlight);
|
||||
return () => {
|
||||
eventDispatcher.off('export-annotations', handleExportMarkdown);
|
||||
eventDispatcher.off('clear-annotations', handleClearAnnotations);
|
||||
eventDispatcher.off('import-annotations', handleImportAnnotations);
|
||||
eventDispatcher.off('create-tts-highlight', handleCreateTTSHighlight);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Type-check**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS. (`useSettingsStore` is already imported at line 13; `getConfig`, `getProgress`, `getViewsById`, `updateBooknotes`, `saveConfig`, `envConfig` are all already in scope per lines 80-85.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/reader/components/annotator/Annotator.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(tts): persist current TTS sentence as a highlight on create-tts-highlight
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the full unit suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: PASS (no regressions; the two new test blocks green).
|
||||
|
||||
- [ ] **Step 2: Lint + type-check**
|
||||
|
||||
Run: `pnpm lint`
|
||||
Expected: PASS (Biome + tsgo clean).
|
||||
|
||||
- [ ] **Step 3: Manual smoke test (dev web)**
|
||||
|
||||
Run: `pnpm dev-web`, open a book, start TTS (`t`), let it read a sentence, then press `Shift+M`. Confirm:
|
||||
- the spoken sentence gets a persistent highlight in the user's default color/style;
|
||||
- pressing the hotkey again on the same sentence does **not** add a second highlight;
|
||||
- pressing it while TTS is stopped does nothing (no error in console);
|
||||
- the highlight survives reopening the book (persisted), and appears in the notebook/annotations list.
|
||||
- the action shows up in the keyboard-shortcuts help dialog (`Shift+?`) under "Text to Speech".
|
||||
|
||||
- [ ] **Step 4: (Optional) i18n extraction**
|
||||
|
||||
The new `_('Highlight Current Sentence')` string uses key-as-content, so tests/lint pass without extraction. If desired, run the project i18n extraction (`/i18n` skill or `pnpm i18n`) to sync locale catalogs; this is not required for verification to pass and may touch unrelated locale files — keep it out of the feature commits if run.
|
||||
|
||||
---
|
||||
|
||||
## Notes for the implementer
|
||||
|
||||
- **No production test seams.** `getSpokenSentence` is tested by setting the private `#ttsSectionIndex` through the public `controller.initViewTTS(0)` path, then overriding `mockView.tts`/`mockView.getCFI` — exactly how the existing `forward`/`backward`/`start` tests in that suite operate.
|
||||
- **Granularity is always sentence.** All TTS clients report only `'sentence'` from `getGranularities()`, so `view.tts.getLastRange()` is always a sentence Range — no word-vs-sentence branching is needed.
|
||||
- **Two events, three components, by design.** Only `TTSController` (via `useTTSControl`) can produce the CFI; only `Annotator` owns highlight persistence. The relay mirrors the existing `tts-forward`/`tts-backward` shortcut pattern rather than duplicating annotation logic.
|
||||
- **bookKey matching everywhere.** Both new handlers compare `detail.bookKey === bookKey`, so split-view (two open books) routes the highlight to the correct book.
|
||||
```
|
||||
@@ -1,307 +0,0 @@
|
||||
# Gesture-Based Brightness Control (iOS / Android)
|
||||
|
||||
GitHub issue: https://github.com/readest/readest/issues/3021
|
||||
|
||||
## Summary
|
||||
|
||||
Add a left-edge vertical swipe gesture that adjusts screen brightness while
|
||||
reading, without opening the menu. While adjusting, a vertical progress bar with
|
||||
a Sun icon appears at the left edge to indicate the current brightness level.
|
||||
|
||||
The feature is gated to platforms with native brightness control (iOS and
|
||||
Android — `appService.hasScreenBrightness`). It is on by default but can be
|
||||
disabled via a setting, works in both paginated and scrolled modes, and persists
|
||||
the chosen brightness across sessions.
|
||||
|
||||
## Locked decisions
|
||||
|
||||
- **Enablement**: on by default for iOS/Android, with an opt-out toggle in
|
||||
**Settings → Behavior → Device**. The toggle doubles as the discoverability
|
||||
surface and the escape hatch for accidental activation (CEO review, both
|
||||
voices). New setting: `swipeBrightnessGesture: boolean` (default `true`) in
|
||||
`SystemSettings`.
|
||||
- **Persistence**: on release, save `screenBrightness` (0–100) and set
|
||||
`autoScreenBrightness = false`, exactly like the existing menu slider, so the
|
||||
value survives restart and stays in sync with the slider. Undo path: the menu
|
||||
slider's "System Screen Brightness" toggle re-enables auto-brightness (CEO
|
||||
review: an accidental swipe silently disables auto-brightness; the undo must be
|
||||
documented and reachable).
|
||||
- **Scope**: core only. One opt-out toggle (above). No sensitivity setting, no
|
||||
corner choice, no volume gesture, no haptics, no lock.
|
||||
- **Gesture area**: left **10%** of the view width, in **both** paginated and
|
||||
scrolled modes.
|
||||
- **Direction**: swipe up = brighter, swipe down = dimmer. A full view-height
|
||||
drag spans the full 0→100% range.
|
||||
|
||||
## Behavior
|
||||
|
||||
1. A touch begins inside the left 10% of the view width.
|
||||
2. It activates as a brightness gesture once movement becomes dominantly
|
||||
vertical (`|Δy| > |Δx|`) and passes a ~18px threshold. The threshold is
|
||||
deliberately above incidental thumb-jitter (CEO review: 10px was too eager
|
||||
for an always-on edge strip).
|
||||
3. While active, device brightness updates live (throttled via
|
||||
`requestAnimationFrame`), and the overlay shows the current level.
|
||||
4. On release, the value is persisted (`screenBrightness` + `autoScreenBrightness
|
||||
= false`) and the overlay fades out shortly after.
|
||||
|
||||
Brightness mapping: `next = clamp(startBrightness − Δy / viewHeight, 0, 1)`,
|
||||
where `startBrightness` is the device brightness captured at activation.
|
||||
|
||||
## Conflict suppression (key design point)
|
||||
|
||||
The existing iframe touch listeners (`FoliateViewer.tsx` ~line 326) are passive
|
||||
and forward events via `postMessage` to `useTouchEvent` / the interceptor chain,
|
||||
which drives page-flip swipes and the upward-swipe-to-toggle-UI behavior. In
|
||||
scrolled mode the iframe also scrolls natively on a vertical drag.
|
||||
|
||||
Attach a **dedicated capture-phase, non-passive** touch listener on the iframe
|
||||
`doc`: `addEventListener('touch{start,move,end,cancel}', fn, { capture: true,
|
||||
passive: false })`. The callback is a parent-realm closure, so it can call the
|
||||
device store and React state directly — no `postMessage` or interceptor needed.
|
||||
|
||||
**Why capture phase (corrected after eng review — this was a bug in the first
|
||||
draft).** There are *three* independent touch-listener registrants on the same
|
||||
`doc`: (1) FoliateViewer's own `postMessage` forwarders (`FoliateViewer.tsx:326`,
|
||||
passive), (2) the Annotator's non-passive selection listeners (`Annotator.tsx:332`),
|
||||
and (3) **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`,
|
||||
non-passive, bubble-phase), registered during `view.open()` — i.e. *before* any
|
||||
app-level listener. The paginator's `touchmove` can `preventDefault()`, set
|
||||
`#touchScrolled`, and `scrollBy()`. A bubble-phase listener registered later
|
||||
therefore **cannot** `stopImmediatePropagation` the paginator — it already ran.
|
||||
Both eng voices (Claude + Codex) independently verified this against `paginator.js`.
|
||||
A **capture-phase** listener fires before every bubble-phase listener regardless
|
||||
of registration order, so its `stopImmediatePropagation` suppresses paginator,
|
||||
Annotator, and FoliateViewer handlers alike.
|
||||
|
||||
When the gesture is active, each move/end/cancel calls `preventDefault()` +
|
||||
`stopImmediatePropagation()`. The latter is the *sole* mechanism that suppresses
|
||||
the conflicting page-flip / upward-swipe-to-toggle-UI (`useIframeEvents.ts:282` —
|
||||
also 10px, vertical, left-edge-inclusive); there is no threshold gap to rely on.
|
||||
The ~18px activation threshold only reduces *accidental* starts.
|
||||
|
||||
**Selection guard.** Before arming, the listener checks `doc.getSelection()`; if a
|
||||
non-collapsed selection exists, it does not arm (mirrors `paginator.js:1622`). This
|
||||
keeps a vertical text-selection drag that starts in the left strip from being
|
||||
hijacked into a brightness change.
|
||||
|
||||
**Scrolled-mode timing.** In scrolled mode the paginator does not `preventDefault`
|
||||
(it early-returns at `paginator.js:1613`); native container scroll is what moves
|
||||
content. `preventDefault` only takes effect once called, so the pre-activation
|
||||
travel (≤18px) would scroll before brightness takes over. Decision: see the
|
||||
"Scrolled-strip reservation" item in the review report — the implementation
|
||||
`preventDefault`s from the first move of any touch that *armed* in the strip
|
||||
(reserve the strip), so there is no scroll-then-freeze jump.
|
||||
|
||||
Before arming, and for taps / horizontal swipes / touches outside the strip, the
|
||||
listener does nothing, so normal page-turn taps and swipes are unaffected.
|
||||
|
||||
Required test: a short upward flick inside the left 10% must adjust brightness and
|
||||
never toggle the toolbar (asserted by spying `stopImmediatePropagation` with a
|
||||
fake paginator listener registered *first*, proving capture-phase suppression).
|
||||
|
||||
## Components
|
||||
|
||||
### `src/app/reader/utils/brightnessGesture.ts` (pure, unit-tested)
|
||||
|
||||
- `isInLeftEdge(x: number, viewWidth: number, edgeRatio = 0.1): boolean`.
|
||||
**Use `screenX` + the parent `window.innerWidth`, NOT `clientX` /
|
||||
`documentElement.clientWidth`.** In paginated mode foliate-js lays content out
|
||||
as side-by-side columns, so the iframe document is many screens wide and
|
||||
`clientX` is a document coordinate (a left-edge touch on a later page reports a
|
||||
large `clientX`). `screenX` is the physical screen position; the listener runs
|
||||
in the parent realm so `window.innerWidth` is the real app viewport. (Matches
|
||||
how `useIframeEvents` / `usePagination` already do zone detection.)
|
||||
- `shouldActivate(deltaX: number, deltaY: number, threshold: number): boolean`
|
||||
— true when `|Δy| >= threshold && |Δy| > |Δx|`.
|
||||
- `computeBrightness(startPos: number, deltaY: number, viewHeight: number): number`
|
||||
— works in **perceptual position** space (0–1) to match the menu slider:
|
||||
`pos = clamp(startPos − deltaY / viewHeight, 0, 1)`, then brightness value =
|
||||
`positionToValue(pos)`. Reuse the slider's `pow(0.5)` curve from `ColorPanel.tsx`
|
||||
(extract `valueToPosition` / `positionToValue` into this module so there is one
|
||||
source of truth — design review, both voices: a linear gesture would land on a
|
||||
different number than the slider for the same finger travel). Always clamp the
|
||||
seed to `[0,1]` and never feed the `-1` sentinel into the curve.
|
||||
|
||||
Constants: `BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1`,
|
||||
`BRIGHTNESS_GESTURE_ACTIVATION_PX = 18`.
|
||||
|
||||
### `src/app/reader/hooks/useBrightnessGesture.ts`
|
||||
|
||||
Inert unless `appService.hasScreenBrightness` AND `settings.swipeBrightnessGesture`.
|
||||
|
||||
**Latest-closure ref (`latestRef`).** The listener is attached once per doc (the
|
||||
`isEventListenersAdded` guard) from a `docLoadHandler` that itself is captured
|
||||
with a `[view]` dependency — so it sees stale render values. Therefore the
|
||||
listener must read everything runtime-variable from a single `latestRef` updated
|
||||
each render (mirrors `handlePageFlipRef` / `useTouchInterceptor`): the live
|
||||
`swipeBrightnessGesture` toggle, `viewSettings.scrolled` / `.vertical`, and the
|
||||
seed brightness. It must NOT read values captured in the hook's render closure.
|
||||
|
||||
**Seed priming (async-race fix).** On mount (when `hasScreenBrightness`), prime a
|
||||
`seedBrightnessRef`: if `settings.screenBrightness ≥ 0` use it, else
|
||||
`await getScreenBrightness()`, clamped to `[0,1]`; fall back to `0.5` if the read
|
||||
fails or returns `< 0`. Multi-pane coherence: seed each gesture-start from the
|
||||
**shared** `settings.screenBrightness` (via `latestRef`), not a private per-book
|
||||
cache, so two grid panes don't drift. A late-resolving seed must not overwrite a
|
||||
value the user has already adjusted this gesture.
|
||||
|
||||
Owns refs: `touchStart`, `armed`, `active`, `startPos`, `rafId`, `hideTimer`,
|
||||
`seedBrightnessRef`, `latestRef`. Exposes `registerBrightnessListeners(doc)` and
|
||||
`{ overlayVisible, overlayLevel }`.
|
||||
|
||||
Listener logic (capture phase):
|
||||
|
||||
- **touchstart**: if `!latestRef.swipeBrightnessGesture` → ignore. If
|
||||
`doc.getSelection()` is non-collapsed → ignore (selection guard). Else record
|
||||
start `clientX/clientY`; `armed = isInLeftEdge(...)`.
|
||||
- **touchmove**: if `armed`:
|
||||
- scrolled mode → `preventDefault()` from this first move (reserve the strip; no
|
||||
scroll-then-freeze jump).
|
||||
- once `active || shouldActivate(...)`: set `active`, `preventDefault()`,
|
||||
`stopImmediatePropagation()`, compute brightness via the perceptual curve,
|
||||
coalesce `setScreenBrightness` through a single `requestAnimationFrame`
|
||||
(store `rafId`, cancel the prior frame), set `{ overlayVisible, overlayLevel }`.
|
||||
- **touchend / touchcancel**: if `active`: `preventDefault()`,
|
||||
`stopImmediatePropagation()`, **cancel any pending `rafId`**, apply the final
|
||||
level deterministically, persist (`saveSysSettings('screenBrightness',
|
||||
round(value*100))` + `saveSysSettings('autoScreenBrightness', false)`), schedule
|
||||
the overlay hide (`hideTimer`, ~600ms). Always reset `touchStart/armed/active`.
|
||||
- **teardown**: on hook unmount, `cancelAnimationFrame(rafId)` and clear
|
||||
`hideTimer`.
|
||||
|
||||
### `src/app/reader/components/BrightnessOverlay.tsx`
|
||||
|
||||
A self-contained **capsule** (its own surface — `bg-base-100/90`, `border-base-content/20`)
|
||||
holding a `PiSun` icon (`react-icons/pi`), a vertical track (`bg-base-content/20`)
|
||||
filled from the bottom to `overlayLevel` (`bg-base-content`), and a small numeric
|
||||
`%` label (`Math.round(value*100)`). The capsule surface is required so the
|
||||
overlay stays legible over any book background (white / sepia / black / image
|
||||
themes) — a bare bar would vanish on a same-tone page (design review, both voices).
|
||||
|
||||
- **Position**: physical `left` + `env(safe-area-inset-left)`, vertically centered
|
||||
within the inset-aware content box (not the raw viewport, so it never lands under
|
||||
a half-open FooterBar). `z-[15]` (above the z-10 header/footer/Ribbon tier),
|
||||
`pointer-events: none`. RTL: keep physical left (user-locked); set `dir="ltr"`
|
||||
on the capsule so the `%` reads correctly.
|
||||
- **Timing (color themes)**: fill height has **no CSS transition** (tracks the
|
||||
finger 1:1); the capsule fades in fast (~100ms), holds ~500ms after release,
|
||||
fades out ~200ms, then unmounts. Appears immediately on activation, never dims
|
||||
the Sun icon with level (full opacity at 0%). 0% keeps the track + border + `0%`
|
||||
visible; 100% fills flush.
|
||||
- **e-ink (`[data-eink]`)**: `eink-bordered`, no shadow/gradient, 1px border, and
|
||||
**no continuous animation** — quantize the visual fill to ~10% steps and drop
|
||||
the fade (show/hide instantly) so the panel repaints a handful of times, not
|
||||
60×/s. Device brightness still updates live; only the overlay repaint is stepped.
|
||||
- **Reduced motion** (`prefers-reduced-motion: reduce`): drop the opacity fades,
|
||||
show/hide instantly (the live fill is functional, not decorative).
|
||||
- `aria-hidden` (transient; the labeled menu slider is the canonical control).
|
||||
|
||||
Positioned `absolute` within the per-book view container (sibling of the iframe in
|
||||
FoliateViewer), so in a multi-pane grid each book's overlay stays in its own pane.
|
||||
|
||||
### `src/app/reader/components/FoliateViewer.tsx`
|
||||
|
||||
- `const { registerBrightnessListeners, overlayVisible, overlayLevel } =
|
||||
useBrightnessGesture(bookKey)`.
|
||||
- Call `registerBrightnessListeners(detail.doc)` inside the existing
|
||||
`isEventListenersAdded` block. (Capture-phase registration makes ordering moot,
|
||||
but keep it in this block so it shares the doc lifecycle.)
|
||||
- Render `<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />` as a
|
||||
sibling of the iframe container.
|
||||
|
||||
### `src/types/settings.ts` + `src/services/constants.ts`
|
||||
|
||||
Add `swipeBrightnessGesture: boolean` to `SystemSettings` (default `true` in
|
||||
`DEFAULT_SYSTEM_SETTINGS`). Surface the toggle in the settings UI under
|
||||
**Behavior → Device** (next to the existing "System Screen Brightness" control in
|
||||
`ControlPanel.tsx`), gated on `appService.hasScreenBrightness`. i18n: add the
|
||||
label + description strings.
|
||||
|
||||
## Testing
|
||||
|
||||
The pure helpers are the easy part; the bug-prone logic is in the listener. Both
|
||||
layers get tests (eng review: the first draft tested only the trivial helpers).
|
||||
|
||||
- **Pure helpers** (`brightnessGesture.test.ts`, failing-first):
|
||||
edge detection at the exact 10% boundary; `shouldActivate` at the 18px boundary
|
||||
and the `|Δy| == |Δx|` tie; perceptual curve round-trip
|
||||
(`positionToValue(valueToPosition(v)) ≈ v`); up = brighter sign; `[0,1]` clamp
|
||||
including an unseeded / `-1` start.
|
||||
- **Listener-level integration** (`useBrightnessGesture.test.ts`): build a fake
|
||||
`Document`, call `registerBrightnessListeners`, dispatch synthetic
|
||||
touchstart/move/end/cancel sequences, and assert:
|
||||
- capture-phase suppression — register a fake paginator listener *first*; a
|
||||
left-strip upward flick must call `stopImmediatePropagation` so the fake never
|
||||
fires (this is the test that fails with bubble-phase, proving the fix);
|
||||
- horizontal swipe in the strip → not activated (page-flip preserved);
|
||||
- selection-in-progress in the strip → not hijacked;
|
||||
- scrolled mode → `preventDefault` called on the armed pre-activation move;
|
||||
- gating → inert when `!hasScreenBrightness` or `!swipeBrightnessGesture`;
|
||||
- persistence on end → `saveSysSettings('screenBrightness', …)` +
|
||||
`autoScreenBrightness=false` (mock `saveSysSettings`/`setScreenBrightness`);
|
||||
- `rafId` / `hideTimer` cancelled on touchend and unmount.
|
||||
- **Manual on device**: live feel, overlay appear/fade + e-ink stepped repaint,
|
||||
scroll reservation in scrolled mode, taps / page-turn swipes still work, and the
|
||||
Settings → Behavior → Device toggle disables the gesture.
|
||||
|
||||
## Out of scope (deferred)
|
||||
|
||||
Sensitivity setting, corner/edge choice, right-edge volume gesture, haptic
|
||||
feedback, gesture lock. These are listed in the issue but explicitly deferred.
|
||||
|
||||
## What already exists (reused, not rebuilt)
|
||||
|
||||
- `deviceStore.getScreenBrightness/setScreenBrightness` (0–1) → live brightness.
|
||||
- `saveSysSettings` + the `screenBrightness` / `autoScreenBrightness` settings →
|
||||
persistence, identical to the menu slider.
|
||||
- `ColorPanel.tsx` perceptual `pow(0.5)` curve → extracted and shared.
|
||||
- `useTouchInterceptor` / `handlePageFlipRef` → latest-closure ref precedent.
|
||||
- `Annotator.tsx` non-passive doc listener → precedent (we go one further: capture).
|
||||
- `appService.hasScreenBrightness` → platform gate.
|
||||
|
||||
---
|
||||
|
||||
## GSTACK REVIEW REPORT (/autoplan — CEO + Design + Eng, dual voices)
|
||||
|
||||
Mode: SELECTIVE EXPANSION. Codex + Claude subagent per phase. Premise confirmed
|
||||
by user. Plan revised in place per the findings below.
|
||||
|
||||
### Consensus
|
||||
|
||||
- **CEO**: premise CONFIRMED (parity with Moon+/KOReader). Both voices challenged
|
||||
"no toggle", "left edge", "10px". User decided: add opt-out toggle
|
||||
(Settings→Behavior→Device, default on); keep left edge; raise to 18px.
|
||||
- **Design**: e-ink stepped repaint (no continuous animation), self-contained
|
||||
contrast capsule, perceptual-curve + `%` label, fill-vs-fade timing split,
|
||||
`z-[15]` + `pointer-events:none`, reduced-motion, RTL physical-left + `dir=ltr`.
|
||||
- **Eng**: 🔴 the original bubble-phase + `stopImmediatePropagation` suppression
|
||||
was **refuted by both voices** (foliate-js paginator registers first, bubble) →
|
||||
**capture-phase** non-passive listener + `touchcancel`. Plus: selection guard,
|
||||
eager+clamped brightness seed, rAF/timer teardown, shared-settings seed for
|
||||
multi-pane, iframe-doc coordinate space, listener-level test harness.
|
||||
|
||||
### Cross-phase theme
|
||||
|
||||
**Gesture-conflict correctness** surfaced in all three phases (CEO flagged the
|
||||
rationale as unverified; both Eng voices proved it false). Highest-confidence
|
||||
signal — the capture-phase fix is the single most important change.
|
||||
|
||||
### Decision Audit Trail
|
||||
|
||||
| # | Phase | Decision | Class | Principle | Rationale |
|
||||
|---|-------|----------|-------|-----------|-----------|
|
||||
| 1 | CEO | Ship the gesture | Premise gate | — | User confirmed; parity feature |
|
||||
| 2 | CEO | Add opt-out toggle (default on) | User challenge | — | User chose: Settings→Behavior→Device |
|
||||
| 3 | CEO | Keep left edge | User challenge | — | User chose; preserves overlay placement |
|
||||
| 4 | CEO | Threshold 10→18px | Taste | P5 explicit | Both voices: 10px too eager |
|
||||
| 5 | CEO | Document auto-brightness undo path | Mechanical | P1 | Silent side-effect needs reachable undo |
|
||||
| 6 | Eng | Capture-phase listener + touchcancel | Mechanical | P5 | Both voices proved bubble-phase insufficient |
|
||||
| 7 | Eng | Selection guard before arming | Mechanical | P1 | Prevent selection hijack in strip |
|
||||
| 8 | Eng | Eager + clamped brightness seed | Mechanical | P1 | Fix async race on default -1/auto |
|
||||
| 9 | Eng | rAF / hide-timer teardown | Mechanical | P1 | Prevent stale write / leak |
|
||||
| 10 | Eng | Listener-level integration tests | Mechanical | P1 | Cover the actual bug-prone branches |
|
||||
| 11 | Design | Perceptual curve reuse + `%` | Mechanical | P4 DRY | One source of truth vs slider |
|
||||
| 12 | Design | e-ink stepped, no continuous anim | Mechanical | P1 | Project e-ink rules |
|
||||
| 13 | Design | Contrast capsule surface | Mechanical | P1 | Legible over any book theme |
|
||||
| T1 | Eng | Scrolled-strip reservation (preventDefault from arm) | **Taste — open** | P5 | See final gate |
|
||||
-153
@@ -1,153 +0,0 @@
|
||||
# Line-Aware Reading Ruler — Design
|
||||
|
||||
**Date:** 2026-05-29
|
||||
**Status:** Approved (pending spec review)
|
||||
|
||||
## Problem
|
||||
|
||||
The reading ruler is a band overlay that helps the user track lines while reading.
|
||||
Tapping the screen advances the band forward/backward by exactly one ruler height
|
||||
(`stepReadingRulerPosition` in `src/app/reader/utils/readingRuler.ts`), where the
|
||||
ruler height is computed arithmetically as `lines × fontSize × lineHeight`.
|
||||
|
||||
That arithmetic height rarely matches the *actual* rendered line-to-line distance:
|
||||
fonts, inline images, headings, ruby text, and CSS overrides all shift real line
|
||||
positions. The error accumulates across taps, so text drifts out of the band's
|
||||
center and the user must manually drag the ruler to recenter it — which makes the
|
||||
feature tedious and undercuts its purpose.
|
||||
|
||||
The on-page-change auto-move (`ReadingRuler.tsx`, the `getFirstVisibleTextPosition`
|
||||
effect) already aligns to the first visible line using real geometry, but
|
||||
within-page taps do not.
|
||||
|
||||
## Goal
|
||||
|
||||
Make tap-advance **line-aware**: snap the band to the next group of `lines` *actual*
|
||||
rendered lines, centered on that block, so text stays centered in the band without
|
||||
manual adjustment. Drift is eliminated because every step is computed from real
|
||||
line geometry rather than an accumulating arithmetic offset.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Sizing model:** Fixed band size — keep the configured band height; snap *position*
|
||||
to real lines rather than dynamically resizing per tap. A small constant padding is
|
||||
added to the rendered band so the centered lines are fully contained with breathing
|
||||
room.
|
||||
- **Scope:** Reflowable horizontal **and** vertical writing mode. Fixed-layout
|
||||
(PDF/CBZ) and scrolled mode keep the current arithmetic stepping as fallback.
|
||||
- **Geometry source:** Reuse `progress.range` (Approach A). `progress.range` already
|
||||
spans the entire first-to-last *visible* text (foliate `#getVisibleRange`,
|
||||
`packages/foliate-js/paginator.js`), and the existing auto-move already reads
|
||||
`progress.range.getClientRects()` mapped to container coords. Since a paginated page
|
||||
does not scroll, those rects are stable for every tap on that page.
|
||||
|
||||
## Snapping rule
|
||||
|
||||
The band advances by exactly `lines` *real* lines per tap and is centered on that
|
||||
block's midpoint:
|
||||
|
||||
- **Forward:** find the first line whose start is at/after the current band's far edge
|
||||
(within a small epsilon). Take that line plus the next `lines − 1` lines to form a
|
||||
block `[blockStart, blockEnd]`. The new band center is `(blockStart + blockEnd) / 2`,
|
||||
clamped to the viewport.
|
||||
- **Backward:** symmetric — find the `lines` lines ending just before the current
|
||||
band's near edge and center on their block midpoint.
|
||||
- **No next group** (already at the last/first line group, or no line geometry):
|
||||
return `null`. The caller falls back to today's behavior — page flip on tap, or
|
||||
`stepReadingRulerPosition` where appropriate.
|
||||
|
||||
"Center on the block midpoint" guarantees that, regardless of how the configured band
|
||||
height compares to the real line block, the lines sit centered with equal clipping on
|
||||
either side in the worst case.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Pure logic — `src/app/reader/utils/readingRuler.ts`
|
||||
|
||||
Two new pure functions (no DOM), unit-tested before implementation:
|
||||
|
||||
- `buildLineBoxes(rects, isVertical, rtl, containerRect): LineBox[]`
|
||||
- Input: plain rect-like objects (from `range.getClientRects()`), orientation flags,
|
||||
and the container rect for coordinate mapping.
|
||||
- Clusters per-fragment rects into visual lines by overlap on the cross axis
|
||||
(horizontal: cluster by vertical overlap → line tops/bottoms; vertical: cluster by
|
||||
horizontal overlap → column spans). Maps to the ruler axis in container coordinates,
|
||||
matching the existing auto-move mapping (`rect.top − containerRect.top` for
|
||||
horizontal; distance-from-edge for vertical-rl/lr).
|
||||
- Returns a sorted array of `{ start, end }` spans along the ruler axis.
|
||||
|
||||
- `snapReadingRulerToLines(currentCenterPx, dimension, lines, direction, lineBoxes): number | null`
|
||||
- Implements the snapping rule above. Returns the next band center in px, or `null`
|
||||
when there is no next group to advance to.
|
||||
|
||||
Existing `clampReadingRulerPosition`, `stepReadingRulerPosition`,
|
||||
`getReadingRulerMoveDirection`, and `calculateReadingRulerSize` are unchanged and remain
|
||||
the fallback path.
|
||||
|
||||
### Glue — `src/app/reader/components/ReadingRuler.tsx`
|
||||
|
||||
- Memoize line boxes per page: rebuild from `progress.range.getClientRects()` when the
|
||||
page (`progress.pageinfo.current`) / range changes or the container size changes.
|
||||
- The page-change auto-move effect and the `reading-ruler-move` tap handler both:
|
||||
1. Compute the snap target via `snapReadingRulerToLines`.
|
||||
2. If it returns a value, animate the band to it (existing `setRulerPosition(_, true)`).
|
||||
3. If it returns `null`, fall back to the current logic
|
||||
(`stepReadingRulerPosition` / no-op so the page flip proceeds).
|
||||
- Render the band at `rulerSize + READING_RULER_LINE_PADDING_PX` so centered lines are
|
||||
fully contained. The padding affects only the rendered band and its overlay/clamp
|
||||
math, not the line-advance computation.
|
||||
|
||||
### Fallback matrix
|
||||
|
||||
| Condition | Behavior |
|
||||
| -------------------------------------- | --------------------------------- |
|
||||
| Reflowable/vertical, line boxes found | Line-aware snap |
|
||||
| `progress.range` missing / no rects | `stepReadingRulerPosition` (arith)|
|
||||
| Scrolled mode | Existing behavior (unchanged) |
|
||||
| Fixed-layout (PDF/CBZ) | `stepReadingRulerPosition` (arith)|
|
||||
| Snap returns `null` (at boundary) | Page flip / no movement (as today)|
|
||||
|
||||
## Testing
|
||||
|
||||
Test-first, per project rule. Pure-function unit tests added to the existing
|
||||
`src/__tests__/utils/readingRuler.test.ts`:
|
||||
|
||||
- `buildLineBoxes`: clusters multi-fragment rects into correct line spans; horizontal vs
|
||||
vertical-rl vs vertical-lr mapping; ignores zero-size rects; sorted output.
|
||||
- `snapReadingRulerToLines`: forward/backward advance by exactly `lines`; centers on
|
||||
block midpoint; returns `null` at boundaries; respects clamping; degenerate inputs
|
||||
(empty `lineBoxes`, `lines` larger than available).
|
||||
|
||||
Then implement and verify with `pnpm test` and `pnpm lint`.
|
||||
|
||||
## Multi-column layouts (column-aware band)
|
||||
|
||||
In paginated layouts that render more than one column (`view.renderer.columnCount > 1`),
|
||||
clustering visible lines by vertical position alone tangles the two columns once their
|
||||
line grids drift apart (headings/images), causing the band to jump erratically.
|
||||
|
||||
For multi-column horizontal layouts the ruler is **column-aware and spans one column at
|
||||
a time**:
|
||||
|
||||
- `buildReadingRulerColumns(rects, columnCount, overlayWidth, rtl)` groups the
|
||||
overlay-relative rects into columns by x (bucketed by `overlayWidth / columnCount`),
|
||||
then into line boxes within each column, returned in reading order.
|
||||
- Rects are mapped to overlay coordinates with the iframe's frame offset
|
||||
(`frameRect.left/top`), because paginated multi-column pages shift the iframe far
|
||||
off-screen horizontally; vertical-only mapping is insufficient here.
|
||||
- `snapReadingRulerColumns(columnIndex, centerPx, …, columns)` advances within the
|
||||
active column; at the column's end it moves to the first/last group of the
|
||||
next/previous column; past the last/first column it returns `null` → page flip.
|
||||
- The band renders over the active column's horizontal extent; the rest of the page —
|
||||
**including the inactive column** — is dimmed (top/bottom/left/right dim rects).
|
||||
- Single column collapses to the full-width band (one column spanning the viewport);
|
||||
vertical writing mode keeps the flat `buildLineBoxes`/`snapReadingRulerToLines` path.
|
||||
|
||||
## Non-goals / YAGNI
|
||||
|
||||
- No dynamic per-tap band resizing.
|
||||
- No new foliate-js APIs or TreeWalker enumeration (Approach B) — reuse `progress.range`.
|
||||
- No `caretPositionFromPoint` probing (Approach C).
|
||||
- No changes to scrolled mode or fixed-layout ruler behavior beyond keeping them on the
|
||||
existing fallback path.
|
||||
- No new user-facing settings.
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Design
|
||||
|
||||
**Date:** 2026-05-30
|
||||
**Status:** Approved (pending spec review)
|
||||
**Issue:** [#4085](https://github.com/readest/readest/issues/4085)
|
||||
|
||||
## Problem
|
||||
|
||||
While TTS is reading aloud, there is no way to persist the sentence currently
|
||||
being spoken as a highlight without stopping playback, locating the text
|
||||
visually, and dragging to select it. That defeats the purpose of hands-free /
|
||||
eyes-off TTS reading and is inaccessible to users with motor or visual
|
||||
impairments — exactly the audience that benefits most from "react with a single
|
||||
key" capture.
|
||||
|
||||
Today the only workarounds are to stop TTS and select manually, or to take notes
|
||||
outside Readest (which then aren't synced/exported with the book).
|
||||
|
||||
## Goal
|
||||
|
||||
Add a single configurable keyboard action that, while TTS is active, persists the
|
||||
**sentence currently being read aloud** as a normal highlight — same data model,
|
||||
persistence, rendering, sync, and export as any other highlight — using the
|
||||
user's current default highlight style/color. No selection, no looking at the
|
||||
screen.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Default keybinding:** `Shift+M` ("M for mark"), in the existing
|
||||
**"Text to Speech"** shortcut section. A single, easy-to-hit key best fits the
|
||||
eyes-off accessibility motivation. Chosen over the issue's suggested `Ctrl+H`
|
||||
(already bound to *Highlight Selection*), over `Ctrl/Cmd+Shift+H` (the `cmd+`
|
||||
variant collides with browser/OS `Cmd+Shift+H` on macOS, and the matcher fires
|
||||
on any listed key regardless of platform), over `Shift+H` (already an alias for
|
||||
*Go Back*), and over "unbound by default" (the #3772 customization UI does not
|
||||
exist yet, so an unbound default would be unreachable by normal users). `Shift+M`
|
||||
is currently unused, has no browser/OS collision, and behaves identically on all
|
||||
platforms; it remains customizable once #3772 ships.
|
||||
- **Repeat press = skip:** If the current sentence is already highlighted, do
|
||||
nothing. No duplicate, no toggle-delete (an accidental eyes-off repeat must
|
||||
never destroy a highlight), no color cycling.
|
||||
- **Silent:** No toast/confirmation. The highlight simply appears.
|
||||
- **Style/color:** The user's current default —
|
||||
`settings.globalReadSettings.highlightStyle` and
|
||||
`highlightStyles[style]` — identical to a default selection highlight.
|
||||
- **Granularity:** The spoken unit is always a **sentence**. All three TTS
|
||||
clients (`WebSpeechClient`, `EdgeTTSClient`, `NativeTTSClient`) report only
|
||||
`'sentence'` granularity via `getGranularities()`, so `view.tts.getLastRange()`
|
||||
always returns the current sentence Range (never a word).
|
||||
|
||||
## Why this wiring
|
||||
|
||||
Two existing owners must cooperate, and the design connects them rather than
|
||||
duplicating their responsibilities:
|
||||
|
||||
- Only `TTSController` knows **both** `view.tts` (the foliate TTS engine — source
|
||||
of the current sentence Range) **and** `#ttsSectionIndex` (required to convert
|
||||
that Range into a CFI). It already performs exactly this conversion in
|
||||
`dispatchSpeakMark` (`this.view.getCFI(this.#ttsSectionIndex, range)`).
|
||||
- Only `Annotator` owns highlight creation — the `BookNote` data model,
|
||||
idempotency, persistence (`updateBooknotes` + `saveConfig`), rendering
|
||||
(`view.addAnnotation`), and global-annotation fan-out.
|
||||
|
||||
They are connected through the app event bus (`eventDispatcher`), mirroring the
|
||||
existing `tts-forward` / `tts-backward` / `tts-toggle-play` shortcuts that
|
||||
`useBookShortcuts` dispatches and `useTTSControl` handles against the controller
|
||||
ref.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
User presses Shift+M
|
||||
→ useBookShortcuts: onTTSHighlightSentence handler
|
||||
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey })
|
||||
→ useTTSControl: 'tts-highlight-sentence' handler (owns the TTSController ref)
|
||||
const sentence = ttsController.getSpokenSentence(); // { cfi, text } | null
|
||||
if (sentence) eventDispatcher.dispatch('create-tts-highlight',
|
||||
{ bookKey, ...sentence });
|
||||
→ Annotator: 'create-tts-highlight' handler
|
||||
build a BookNote (type 'annotation', default style/color, cfi, text);
|
||||
skip if a non-deleted annotation already exists at that cfi;
|
||||
else updateBooknotes + saveConfig + view.addAnnotation(annotation).
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Shortcut definition — `src/helpers/shortcuts.ts`
|
||||
|
||||
Add to `DEFAULT_SHORTCUTS`:
|
||||
|
||||
```ts
|
||||
onTTSHighlightSentence: {
|
||||
keys: ['shift+m'],
|
||||
description: _('Highlight Current Sentence'),
|
||||
section: 'Text to Speech',
|
||||
},
|
||||
```
|
||||
|
||||
`ShortcutConfig` is derived from `DEFAULT_SHORTCUTS`, so the type updates
|
||||
automatically and the action appears in the shortcuts help dialog with no extra
|
||||
work. When the #3772 customization UI lands, this action is customizable like any
|
||||
other.
|
||||
|
||||
### 2. Current-sentence resolver — `src/services/tts/TTSController.ts`
|
||||
|
||||
New public method, the single source of truth for "what sentence is being
|
||||
spoken right now":
|
||||
|
||||
```ts
|
||||
getSpokenSentence(): { cfi: string; text: string } | null {
|
||||
const range = this.view.tts?.getLastRange();
|
||||
if (!range || this.#ttsSectionIndex < 0) return null;
|
||||
try {
|
||||
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
|
||||
const text = range.toString().trim();
|
||||
if (!cfi || !text) return null;
|
||||
return { cfi, text };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `null` when TTS is inactive (`view.tts` is cleared on `shutdown`) or no
|
||||
sentence is current — the natural no-op gate. Works whether TTS is playing or
|
||||
paused (a paused controller keeps `view.tts` and the last mark).
|
||||
|
||||
### 3. Shortcut handler — `src/app/reader/hooks/useBookShortcuts.ts`
|
||||
|
||||
New handler mirroring `ttsGoNextSentence`, registered as `onTTSHighlightSentence`:
|
||||
|
||||
```ts
|
||||
const ttsHighlightSentence = () => {
|
||||
if (!sideBarBookKey) return;
|
||||
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Resolver glue — `src/app/reader/hooks/useTTSControl.ts`
|
||||
|
||||
New handler for `'tts-highlight-sentence'`, registered/cleaned up alongside the
|
||||
existing TTS event listeners (`tts-forward`, `tts-backward`, …):
|
||||
|
||||
```ts
|
||||
const handleTTSHighlightSentence = (event: CustomEvent) => {
|
||||
const detail = event.detail as { bookKey: string } | undefined;
|
||||
if (detail?.bookKey !== bookKey) return;
|
||||
const sentence = ttsControllerRef.current?.getSpokenSentence();
|
||||
if (!sentence) return;
|
||||
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Highlight creation — pure helper + Annotator event handler
|
||||
|
||||
The selection path (`handleHighlight`) is selection-coupled and does more than
|
||||
the TTS path needs (it reads `selection`, computes the CFI from
|
||||
`selection.range`, updates an existing note, carries the `global` flag, and
|
||||
drives popup/selection UI state). The TTS path is strictly simpler — create or
|
||||
skip — so rather than overloading `handleHighlight`, the bug-prone *decision* is
|
||||
extracted as a pure, unit-testable helper and the Annotator keeps the
|
||||
React/persistence glue.
|
||||
|
||||
**Pure helper — `src/app/reader/utils/annotatorUtil.ts`:**
|
||||
|
||||
```ts
|
||||
export function buildTTSSentenceHighlight(
|
||||
annotations: BookNote[],
|
||||
params: { cfi: string; text: string; style: HighlightStyle; color: HighlightColor; page?: number },
|
||||
now: number,
|
||||
): BookNote | null {
|
||||
const exists = annotations.some(
|
||||
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
|
||||
);
|
||||
if (exists) return null; // idempotent: skip duplicates (locked decision)
|
||||
return {
|
||||
id: uniqueId(),
|
||||
type: 'annotation',
|
||||
note: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...params,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
This mirrors the idempotency predicate already used inline in `handleHighlight`
|
||||
(`annotations.findIndex(a => a.cfi === cfi && a.type === 'annotation' && a.style
|
||||
&& !a.deletedAt)`). `now` is injected so the helper is deterministic for tests.
|
||||
|
||||
**Annotator — `src/app/reader/components/annotator/Annotator.tsx`:**
|
||||
|
||||
A new `'create-tts-highlight'` event handler (bookKey-matched), subscribed
|
||||
alongside the other `eventDispatcher` listeners: read default `style`/`color`
|
||||
from `settings.globalReadSettings`, call `buildTTSSentenceHighlight`; if it
|
||||
returns `null`, do nothing; otherwise push the note, `updateBooknotes` +
|
||||
`saveConfig`, and `view.addAnnotation(annotation)` for each view — the same
|
||||
persistence/render calls the selection path makes.
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Condition | Behavior |
|
||||
| --------- | -------- |
|
||||
| TTS not playing / no current sentence | `getSpokenSentence()` → `null` → silent no-op |
|
||||
| Sentence already highlighted | idempotency skip — no duplicate (locked decision) |
|
||||
| TTS reading a not-yet-visible section | CFI uses the TTS section index; note is saved and draws when that section renders (existing `onCreateOverlay` path) |
|
||||
| Live gray TTS cursor overlay overlaps the new highlight | distinct overlay keys; the gray cursor moves on as TTS advances, leaving the persistent highlight |
|
||||
| Wrong book (split view) | every handler is bookKey-matched, as the existing TTS handlers are |
|
||||
|
||||
## Testing
|
||||
|
||||
Test-first, per project rule. Both new units are testable with **no production
|
||||
test seams**:
|
||||
|
||||
- **`TTSController.getSpokenSentence()`** — add to
|
||||
`src/__tests__/services/tts-controller.test.ts`. The private
|
||||
`#ttsSectionIndex` is set through the public `await controller.initViewTTS(0)`
|
||||
path (already exercised in that suite); `mockView.tts.getLastRange` and
|
||||
`mockView.getCFI` are stubbed via the existing `createMockView` helper.
|
||||
Cases: returns `{ cfi, text }` (trimmed) when a sentence is active; returns
|
||||
`null` when `view.tts` is absent (pre-init), when `getLastRange()` is
|
||||
undefined, when `getCFI` throws, and when the range text is empty/whitespace.
|
||||
- **`buildTTSSentenceHighlight`** — add to
|
||||
`src/__tests__/utils/annotator-util.test.ts`. Cases: builds a `BookNote`
|
||||
(`type: 'annotation'`, given `cfi`/`text`/`style`/`color`/`page`, injected
|
||||
timestamps) when none exists; returns `null` when a non-deleted annotation
|
||||
already exists at that `cfi`; still builds when the only match at that `cfi`
|
||||
is `deletedAt`-soft-deleted or a non-annotation (`bookmark`).
|
||||
|
||||
Then verify with `pnpm test` and `pnpm lint`.
|
||||
|
||||
## Non-goals / YAGNI
|
||||
|
||||
- No toast/confirmation, no haptics (locked: silent).
|
||||
- No toggle-to-remove and no color cycling on repeat (locked: skip).
|
||||
- No shortcut-customization UI — that is #3772; this only adds one entry to the
|
||||
existing registry, which #3772 will expose.
|
||||
- No word-level highlighting (#4017) — out of scope; granularity is always
|
||||
sentence in practice.
|
||||
- No new user-facing settings.
|
||||
- No change to selection-based highlighting behavior.
|
||||
@@ -1,110 +0,0 @@
|
||||
# Testing
|
||||
|
||||
Readest uses three test tiers, all powered by [Vitest](https://vitest.dev/).
|
||||
|
||||
## Unit Tests (`pnpm test`)
|
||||
|
||||
Runs tests in a **jsdom** environment. No browser or Tauri runtime required.
|
||||
|
||||
```bash
|
||||
pnpm test # Run all unit tests
|
||||
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single file
|
||||
pnpm test -- --watch # Watch mode
|
||||
```
|
||||
|
||||
- **Config:** `vitest.config.mts`
|
||||
- **Pattern:** `src/**/*.test.ts` (excludes `*.browser.test.ts` and `*.tauri.test.ts`)
|
||||
- **Environment:** jsdom
|
||||
- **Use for:** Pure logic, utilities, services that don't need real browser APIs or Tauri IPC.
|
||||
|
||||
## Browser Tests (`pnpm test:browser`)
|
||||
|
||||
Runs tests in a **real Chromium** browser via Playwright. Required for code that depends on Web Workers, SharedArrayBuffer, OPFS, or other browser-only APIs.
|
||||
|
||||
```bash
|
||||
pnpm test:browser
|
||||
```
|
||||
|
||||
- **Config:** `vitest.browser.config.mts`
|
||||
- **Pattern:** `src/**/*.browser.test.ts`
|
||||
- **Browser:** Chromium (headless, via `@vitest/browser-playwright`)
|
||||
- **Use for:** WASM modules (e.g. `@tursodatabase/database-wasm`), Web Worker integration, browser-specific storage APIs.
|
||||
|
||||
## Tauri Integration Tests (`pnpm test:tauri`)
|
||||
|
||||
Runs Vitest tests **inside the Tauri WebView**, with access to Tauri IPC and native plugin commands. Tests execute in the actual app environment.
|
||||
|
||||
### Step 1: Start the Tauri App
|
||||
|
||||
In one terminal, start the app with the `webdriver` feature enabled:
|
||||
|
||||
```bash
|
||||
pnpm tauri:dev:test # Dev mode (uses tauri dev server, faster iteration)
|
||||
pnpm tauri:build:test # Debug release build (closer to production)
|
||||
```
|
||||
|
||||
These commands compile the Rust backend with `--features webdriver`, which:
|
||||
|
||||
- Includes `tauri-plugin-webdriver` (embeds a W3C WebDriver server on port 4445)
|
||||
- Adds a runtime capability granting plugin permissions to remote URLs (`http://127.0.0.1:*`), so Vitest's browser-mode iframe can call Tauri IPC
|
||||
|
||||
Keep this running while you run tests.
|
||||
|
||||
### Step 2: Run Tests
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
pnpm test:tauri
|
||||
```
|
||||
|
||||
Vitest connects directly to the embedded WebDriver server (port 4445) in the running Tauri app and executes tests inside its WebView.
|
||||
|
||||
- **Config:** `vitest.tauri.config.mts`
|
||||
- **Pattern:** `src/**/*.tauri.test.ts`
|
||||
- **Browser provider:** `@vitest/browser-webdriverio` (connects to port 4445)
|
||||
- **Use for:** Tauri plugin commands (turso, native-tts, etc.), native filesystem, Tauri IPC.
|
||||
|
||||
### Writing Tauri Tests
|
||||
|
||||
Tests access Tauri IPC via a shared helper:
|
||||
|
||||
```typescript
|
||||
import { invoke } from '../tauri/tauri-invoke';
|
||||
|
||||
it('calls a plugin command', async () => {
|
||||
const result = await invoke('plugin:turso|load', { options: { path: 'sqlite::memory:' } });
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
The `invoke()` helper accesses `window.top.__TAURI_INTERNALS__` (Vitest runs in an iframe, Tauri injects IPC into the main frame).
|
||||
|
||||
**Limitations:** Only custom invoke commands and plugin commands listed in the webdriver capability work. Standard Tauri JS APIs (e.g. `@tauri-apps/api`) that rely on `URL: local` may not work from the Vitest iframe.
|
||||
|
||||
## E2E Tests (WDIO)
|
||||
|
||||
Full end-to-end tests using WebDriverIO, for UI-level testing against the running Tauri app. Same two-step workflow as Tauri integration tests.
|
||||
|
||||
```bash
|
||||
# Terminal 1: start the app (same as for Tauri integration tests)
|
||||
pnpm tauri:dev:test
|
||||
|
||||
# Terminal 2: run E2E tests
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
- **Config:** `wdio.conf.ts`
|
||||
- **Pattern:** `e2e/**/*.e2e.ts`
|
||||
- **Framework:** Mocha (via `@wdio/mocha-framework`)
|
||||
- **Connects to:** port 4445 (embedded WebDriver server)
|
||||
- **Use for:** UI interaction tests, window management, navigation flows.
|
||||
|
||||
## Test File Naming
|
||||
|
||||
| Suffix | Runner | Environment |
|
||||
| ------------------- | ------------------- | --------------------- |
|
||||
| `*.test.ts` | `pnpm test` | jsdom |
|
||||
| `*.browser.test.ts` | `pnpm test:browser` | Chromium (Playwright) |
|
||||
| `*.tauri.test.ts` | `pnpm test:tauri` | Tauri WebView |
|
||||
| `*.e2e.ts` | `pnpm test:e2e` | Tauri app (WDIO) |
|
||||
@@ -1,209 +0,0 @@
|
||||
## Adding a Config to `ViewSettings`
|
||||
|
||||
`ViewSettings` is the per-book view state (layout, fonts, colors, TTS, etc.) composed from several sub-interfaces defined in `src/types/book.ts`. A matching `globalViewSettings` lives on `SystemSettings` and acts as the default for every book. The per-book value is derived by merging the global defaults with any overrides stored on the book's `BookConfig`.
|
||||
|
||||
This doc covers how to plumb a new config through the three layers:
|
||||
|
||||
1. **Types** — `src/types/book.ts`
|
||||
2. **Defaults** — `src/services/constants.ts` and `src/services/settingsService.ts`
|
||||
3. **Read/write** — components via `saveViewSettings` from `src/helpers/settings.ts`
|
||||
|
||||
### Pick a Pattern
|
||||
|
||||
**Pattern A — add a field to an existing sub-interface.** Use when the new option belongs to an existing bundle (`BookLayout`, `BookStyle`, `BookFont`, `ViewConfig`, `TTSConfig`, etc.).
|
||||
|
||||
**Pattern B — introduce a new sub-interface.** Use when several related fields cluster together, or when a single field is semantically its own concept (e.g. `ParagraphModeConfig`, `ViewSettingsConfig`). Then extend `ViewSettings` with it.
|
||||
|
||||
Both patterns follow the same three-layer flow. The only difference is whether you reuse an existing `DEFAULT_*` constant or add a new one.
|
||||
|
||||
### Step 1 — Declare the Type
|
||||
|
||||
**Pattern A** — add a required field to the sub-interface that owns this concern:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewConfig {
|
||||
// ...existing fields
|
||||
myNewToggle: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Pattern B** — define a new interface and extend `ViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/types/book.ts
|
||||
export interface ViewSettingsConfig {
|
||||
isGlobal: boolean;
|
||||
}
|
||||
|
||||
export interface ViewSettings
|
||||
extends
|
||||
BookLayout,
|
||||
BookStyle,
|
||||
// ...other bundles
|
||||
ViewSettingsConfig {}
|
||||
```
|
||||
|
||||
Fields should be **required**, not optional. Optional fields make downstream code defensive. Provide a sensible default in Step 2 instead.
|
||||
|
||||
### Step 2 — Provide a Default
|
||||
|
||||
Every field in `ViewSettings` must have a default, otherwise `getDefaultViewSettings()` produces an incomplete object.
|
||||
|
||||
**Pattern A** — add the value to the existing `DEFAULT_*` constant:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_CONFIG: ViewConfig = {
|
||||
// ...existing defaults
|
||||
myNewToggle: false,
|
||||
};
|
||||
```
|
||||
|
||||
**Pattern B** — add a `DEFAULT_*_CONFIG` constant for your new bundle, then register it in `getDefaultViewSettings`:
|
||||
|
||||
```ts
|
||||
// src/services/constants.ts
|
||||
export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = {
|
||||
isGlobal: true,
|
||||
};
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/services/settingsService.ts
|
||||
export function getDefaultViewSettings(ctx: Context): ViewSettings {
|
||||
return {
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
// ...other bundles
|
||||
...DEFAULT_VIEW_SETTINGS_CONFIG,
|
||||
// platform overrides go last so they win
|
||||
...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}),
|
||||
...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}),
|
||||
...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Platform Overrides
|
||||
|
||||
To tweak the default on mobile, e-ink, or CJK locales, add the field to the matching `Partial<ViewSettings>` constant (`DEFAULT_MOBILE_VIEW_SETTINGS`, `DEFAULT_EINK_VIEW_SETTINGS`, `DEFAULT_CJK_VIEW_SETTINGS`). These are spread after the base defaults in `getDefaultViewSettings`, so they override them.
|
||||
|
||||
#### Migration
|
||||
|
||||
Old `settings.json` files on disk won't have your new field. `loadSettings` merges the stored blob over fresh defaults:
|
||||
|
||||
```ts
|
||||
settings.globalViewSettings = {
|
||||
...getDefaultViewSettings(ctx),
|
||||
...settings.globalViewSettings,
|
||||
};
|
||||
```
|
||||
|
||||
So existing users pick up your default automatically — no explicit migration is needed for adding a field. Only bump `SYSTEM_SETTINGS_VERSION` if you are reshaping existing data.
|
||||
|
||||
### Step 3 — Read and Write from Components
|
||||
|
||||
Read the current value by preferring the per-book settings, falling back to the global:
|
||||
|
||||
```tsx
|
||||
const { settings } = useSettingsStore();
|
||||
const { getViewSettings } = useReaderStore();
|
||||
const viewSettings = getViewSettings(bookKey) || settings.globalViewSettings;
|
||||
```
|
||||
|
||||
Write via `saveViewSettings` — never mutate the store directly. The helper handles the global-vs-per-book routing, persists to disk, and re-applies styles when needed.
|
||||
|
||||
```tsx
|
||||
import { saveViewSettings } from '@/helpers/settings';
|
||||
|
||||
const [myNewToggle, setMyNewToggle] = useState(viewSettings.myNewToggle);
|
||||
|
||||
useEffect(() => {
|
||||
saveViewSettings(envConfig, bookKey, 'myNewToggle', myNewToggle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [myNewToggle]);
|
||||
```
|
||||
|
||||
The `useEffect`-on-local-state pattern is the established convention in `LayoutPanel`, `ControlPanel`, `ColorPanel`, etc. It keeps the UI responsive and batches store updates until the user stops interacting.
|
||||
|
||||
#### Signature
|
||||
|
||||
```ts
|
||||
saveViewSettings<K extends keyof ViewSettings>(
|
||||
envConfig,
|
||||
bookKey,
|
||||
key: K,
|
||||
value: ViewSettings[K],
|
||||
skipGlobal = false, // true → only update this book's settings
|
||||
applyStyles = true, // false → don't re-run style recomputation
|
||||
)
|
||||
```
|
||||
|
||||
**Global vs. per-book routing.** `saveViewSettings` inspects `viewSettings.isGlobal` on the target book. When `true` (the default), it writes to `globalViewSettings`, loops through every open book, and saves to disk. When `false`, it writes only to the one book's config.
|
||||
|
||||
**Skip global.** Pass `skipGlobal=true` when the setting is meta — i.e. it describes the settings system itself, not book content. The canonical case is toggling `isGlobal` from `DialogMenu`: you want the scope flag to live on the specific book without propagating it to every other book.
|
||||
|
||||
```tsx
|
||||
saveViewSettings(envConfig, bookKey, 'isGlobal', !isSettingsGlobal, true, false);
|
||||
```
|
||||
|
||||
**Skip styles.** Pass `applyStyles=false` for options that don't affect CSS rendering (toggles, flags, metadata). This avoids an unnecessary `renderer.setStyles` call.
|
||||
|
||||
### Step 4 — Support Reset
|
||||
|
||||
If your field should be resettable from the panel menu, register a setter in the panel's `handleReset` via `useResetViewSettings`:
|
||||
|
||||
```tsx
|
||||
const resetToDefaults = useResetViewSettings();
|
||||
|
||||
const handleReset = () => {
|
||||
resetToDefaults({
|
||||
myNewToggle: setMyNewToggle,
|
||||
// ...other setters
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
The hook resolves the default by reading from `getDefaultViewSettings(ctx)` and calls each provided setter with that value, which then fires your `useEffect` and persists the change.
|
||||
|
||||
### Step 5 — Register in the Command Palette
|
||||
|
||||
If your setting has a visible row in a panel, register it in the matching `*PanelItems` array in `src/services/commandRegistry.ts`. This wires it into the command-palette fuzzy search so users can jump straight to it.
|
||||
|
||||
```ts
|
||||
// src/services/commandRegistry.ts
|
||||
const layoutPanelItems = [
|
||||
// ...existing entries
|
||||
{
|
||||
id: 'settings.layout.myNewToggle',
|
||||
labelKey: _('My New Toggle'),
|
||||
keywords: ['search', 'terms', 'for', 'discoverability'],
|
||||
section: 'Paragraph',
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
- `id` must match the `data-setting-id` attribute on the panel row. The palette uses it to scroll/highlight the target control.
|
||||
- `labelKey` uses `stubTranslation` (imported as `_`) so the extractor picks it up — the same string that appears in the panel.
|
||||
- `keywords` broadens fuzzy-search hits beyond the label; include synonyms, related jargon, and the panel section name.
|
||||
- `section` groups the entry in the palette results (matches the panel's sub-header: `Layout`, `Paragraph`, `Page`, `Header & Footer`, etc.).
|
||||
|
||||
Skip this step only for settings that don't surface as a user-visible row (hidden toggles, flags used by other settings).
|
||||
|
||||
### Don'ts
|
||||
|
||||
- **Don't make the field optional** just to skip providing a default. Add a default in Step 2 instead.
|
||||
- **Don't mutate `settings.globalViewSettings` directly** in a component — `saveViewSettings` already handles global propagation when `isGlobal` is true.
|
||||
- **Don't bump `SYSTEM_SETTINGS_VERSION`** for a plain additive field. The load-time merge handles it.
|
||||
|
||||
### Minimal Checklist
|
||||
|
||||
- [ ] Field or new interface added in `src/types/book.ts`
|
||||
- [ ] Default value in `src/services/constants.ts`
|
||||
- [ ] New `DEFAULT_*_CONFIG` spread into `getDefaultViewSettings` (Pattern B only)
|
||||
- [ ] Optional mobile/eink/CJK override in the matching `Partial<ViewSettings>` constant
|
||||
- [ ] Read via `getViewSettings(bookKey) || settings.globalViewSettings`
|
||||
- [ ] Write via `saveViewSettings(envConfig, bookKey, 'key', value)`
|
||||
- [ ] Reset setter wired into `useResetViewSettings` if the panel has a reset menu
|
||||
- [ ] Command-palette entry added to the matching `*PanelItems` array in `src/services/commandRegistry.ts`, with an `id` that matches the panel row's `data-setting-id`
|
||||
@@ -1,48 +0,0 @@
|
||||
# End-to-end tests
|
||||
|
||||
Readest has two end-to-end lanes. They cover different layers and are run
|
||||
separately.
|
||||
|
||||
## Web lane — Playwright
|
||||
|
||||
Drives the Next.js **web** build (`pnpm dev-web`) in a real browser. Fast, no
|
||||
Rust build required. Tests run unauthenticated against a fresh browser
|
||||
context, so each test starts from an isolated, empty local library.
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:web # run the web e2e suite (auto-starts pnpm dev-web)
|
||||
pnpm test:e2e:web:headed # run headed, one test at a time, with traces
|
||||
pnpm test:e2e:web:ui # run in the Playwright UI mode
|
||||
pnpm test:e2e:web:report # open the last HTML report
|
||||
```
|
||||
|
||||
Every run writes an HTML report to `playwright-report/`; open it with
|
||||
`pnpm test:e2e:web:report`.
|
||||
|
||||
Layout:
|
||||
|
||||
| Path | Purpose |
|
||||
| --------------------------------- | ------------------------------------------------------ |
|
||||
| `playwright.config.ts` (app root) | Runner config, projects, web server. |
|
||||
| `e2e/tests/` | Specs (`*.spec.ts`). |
|
||||
| `e2e/pages/` | Page Object Model — actions/queries, no assertions. |
|
||||
| `e2e/fixtures/` | Shared fixtures; `fixtures/books/` holds sample books. |
|
||||
|
||||
Page objects expose locators and actions; assertions stay in the specs so
|
||||
failures point at test intent. To add coverage, prefer extending a page
|
||||
object over inlining selectors in a spec.
|
||||
|
||||
The demo-book auto-import (`useDemoBooks`) is suppressed by the base fixture
|
||||
so the library is deterministic; authenticated/sync flows are out of scope
|
||||
for this lane until a test account is provisioned.
|
||||
|
||||
## Tauri lane — WebdriverIO
|
||||
|
||||
Drives the actual **Tauri** desktop shell via `tauri-driver`. Use this for
|
||||
coverage that depends on the native build (Rust integration, window
|
||||
management, platform globals).
|
||||
|
||||
```bash
|
||||
pnpm tauri:dev:test # start the Tauri app with the webdriver feature
|
||||
pnpm test:e2e # run wdio against it (specs: e2e/*.e2e.ts)
|
||||
```
|
||||
@@ -1,123 +0,0 @@
|
||||
describe('Readest App Launch', () => {
|
||||
it('should have a visible body element', async () => {
|
||||
const body = await $('body');
|
||||
await body.waitForDisplayed({ timeout: 10000 });
|
||||
expect(await body.isDisplayed()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have the correct window handle', async () => {
|
||||
const handle = await browser.getWindowHandle();
|
||||
expect(handle).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return the page source', async () => {
|
||||
const source = await browser.getPageSource();
|
||||
expect(source).toContain('html');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Library Page', () => {
|
||||
it('should navigate to the library page', async () => {
|
||||
const url = await browser.getUrl();
|
||||
expect(url).toMatch(/library|localhost/);
|
||||
});
|
||||
|
||||
it('should display the library container', async () => {
|
||||
const library = await $('[aria-label="Your Library"]');
|
||||
await library.waitForExist({ timeout: 15000 });
|
||||
expect(await library.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should display the library header', async () => {
|
||||
const header = await $('[aria-label="Library Header"]');
|
||||
await header.waitForExist({ timeout: 10000 });
|
||||
expect(await header.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should display the bookshelf area', async () => {
|
||||
const bookshelf = await $('[aria-label="Bookshelf"]');
|
||||
await bookshelf.waitForExist({ timeout: 10000 });
|
||||
expect(await bookshelf.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a search input', async () => {
|
||||
const searchInput = await $('.search-input');
|
||||
await searchInput.waitForExist({ timeout: 10000 });
|
||||
expect(await searchInput.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow typing in the search input', async () => {
|
||||
const searchInput = await $('.search-input');
|
||||
await searchInput.waitForDisplayed({ timeout: 10000 });
|
||||
await searchInput.setValue('test search');
|
||||
const value = await searchInput.getValue();
|
||||
expect(value).toBe('test search');
|
||||
});
|
||||
|
||||
it('should show the clear search button after typing', async () => {
|
||||
const clearBtn = await $('[aria-label="Clear Search"]');
|
||||
await clearBtn.waitForExist({ timeout: 5000 });
|
||||
expect(await clearBtn.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should clear the search input when clear button is clicked', async () => {
|
||||
const clearBtn = await $('[aria-label="Clear Search"]');
|
||||
await clearBtn.click();
|
||||
const searchInput = await $('.search-input');
|
||||
const value = await searchInput.getValue();
|
||||
expect(value).toBe('');
|
||||
});
|
||||
|
||||
it('should have a select books button', async () => {
|
||||
const selectBtn = await $('[aria-label="Select Books"]');
|
||||
await selectBtn.waitForExist({ timeout: 10000 });
|
||||
expect(await selectBtn.isExisting()).toBe(true);
|
||||
});
|
||||
|
||||
it('should have an import books button', async () => {
|
||||
const importBtn = await $('[aria-label="Import Books"]');
|
||||
await importBtn.waitForExist({ timeout: 10000 });
|
||||
expect(await importBtn.isExisting()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Window Management', () => {
|
||||
it('should return the window size', async () => {
|
||||
const size = await browser.getWindowSize();
|
||||
expect(size.width).toBeGreaterThan(0);
|
||||
expect(size.height).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JavaScript Execution', () => {
|
||||
it('should execute JavaScript in the app context', async () => {
|
||||
const result = await browser.execute(() => {
|
||||
return document.readyState;
|
||||
});
|
||||
expect(result).toBe('complete');
|
||||
});
|
||||
|
||||
it('should access the document title via JS', async () => {
|
||||
const title = await browser.execute(() => {
|
||||
return document.title;
|
||||
});
|
||||
expect(title).toContain('Readest');
|
||||
});
|
||||
|
||||
it('should detect the app platform globals', async () => {
|
||||
const hasCLIAccess = await browser.execute(() => {
|
||||
return (window as unknown as Record<string, unknown>).__READEST_CLI_ACCESS === true;
|
||||
});
|
||||
expect(hasCLIAccess).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should navigate back to library after visiting another route', async () => {
|
||||
const currentUrl = await browser.getUrl();
|
||||
await browser.url(currentUrl.replace(/\/[^/]*$/, '/library'));
|
||||
const library = await $('[aria-label="Your Library"]');
|
||||
await library.waitForExist({ timeout: 15000 });
|
||||
expect(await library.isExisting()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
import { LibraryPage } from '../pages/LibraryPage';
|
||||
import { ReaderPage } from '../pages/ReaderPage';
|
||||
import { SAMPLE_EPUB } from './books';
|
||||
|
||||
type Fixtures = {
|
||||
/**
|
||||
* Imports a book (the sample EPUB by default), opens it, and returns a
|
||||
* {@link ReaderPage} that is ready to interact with.
|
||||
*/
|
||||
openBook: (filePath?: string) => Promise<ReaderPage>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Base test fixture for the web e2e lane.
|
||||
*
|
||||
* - Overrides `page` to suppress the demo-book auto-import that `useDemoBooks`
|
||||
* performs on a fresh web session (see `src/app/library/hooks/useDemoBooks.ts`),
|
||||
* so every test starts from a deterministic empty library.
|
||||
* - Adds the `openBook` action fixture so reading/annotation specs do not
|
||||
* repeat the import-and-open boilerplate.
|
||||
*/
|
||||
export const test = base.extend<Fixtures>({
|
||||
page: async ({ page }, use) => {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
window.localStorage.setItem('demoBooksFetched', 'true');
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts; ignore.
|
||||
}
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
openBook: async ({ page }, use) => {
|
||||
await use(async (filePath = SAMPLE_EPUB) => {
|
||||
const library = new LibraryPage(page);
|
||||
await library.goto();
|
||||
await library.importBook(filePath);
|
||||
await expect(library.bookCards()).toHaveCount(1);
|
||||
await library.openFirstBook();
|
||||
|
||||
const reader = new ReaderPage(page);
|
||||
await reader.waitForReady();
|
||||
return reader;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -1,17 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const fixturesDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Synthetic plain-text book — fast, used for basic import coverage. */
|
||||
export const SAMPLE_TXT = path.join(fixturesDir, 'books/readest-e2e-sample.txt');
|
||||
|
||||
/**
|
||||
* A real EPUB ("Alice's Adventures in Wonderland") from the unit-test
|
||||
* fixtures. Has multiple chapters and substantial prose, so it exercises
|
||||
* reading and annotation flows realistically.
|
||||
*/
|
||||
export const SAMPLE_EPUB = path.join(
|
||||
fixturesDir,
|
||||
'../../src/__tests__/fixtures/data/sample-alice.epub',
|
||||
);
|
||||
@@ -1,29 +0,0 @@
|
||||
Readest E2E Sample
|
||||
|
||||
Chapter One
|
||||
|
||||
This is a synthetic plain-text book used by the Readest web end-to-end
|
||||
test suite. It exists only so that import and reading flows have a small,
|
||||
deterministic file to exercise. It is not a real publication.
|
||||
|
||||
The text below is intentionally repetitive filler. Its only purpose is to
|
||||
give the paginator enough content to lay out across more than one page so
|
||||
that page-turning can be exercised by the tests.
|
||||
|
||||
Chapter Two
|
||||
|
||||
A reader opens a book, and the words arrange themselves into pages. Each
|
||||
page is a small window onto a longer whole. Turn forward and the window
|
||||
slides ahead; turn back and it returns to where it was.
|
||||
|
||||
Lorem ipsum has long served as placeholder prose, but plain repetition
|
||||
serves a test suite just as well. Sentences accumulate, paragraphs follow,
|
||||
and soon there is enough material for the layout engine to work with.
|
||||
|
||||
Chapter Three
|
||||
|
||||
The end of a sample book is much like its beginning: a few lines of text,
|
||||
arranged for no one in particular, standing in for the real thing. When
|
||||
the test completes, this book is discarded and forgotten.
|
||||
|
||||
The End
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Shared base for page objects.
|
||||
*
|
||||
* Page objects expose actions and queries (locators) only — assertions live
|
||||
* in the specs, so a failing assertion points at test intent rather than at a
|
||||
* helper.
|
||||
*/
|
||||
export abstract class BasePage {
|
||||
constructor(protected readonly page: Page) {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user