forked from akai/readest
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f20ae4967 |
@@ -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 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: describing your feature request'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
|
||||
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
> A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
> Add any additional context or screenshots about the feature request here.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Report a bug
|
||||
about: Report a bug or a functional regression
|
||||
title: 'Example: In DarkMode, a blank square appears in bottom right corner while scrolling'
|
||||
title: 'Ex: In DarkMode, a blank square appears in bottom right corner while scrolling'
|
||||
labels: ['type: bug']
|
||||
assignees: ''
|
||||
---
|
||||
@@ -11,25 +11,28 @@ assignees: ''
|
||||
A clear and concise description of what the current behavior is.
|
||||
Please also add **screenshots** of the existing application.
|
||||
|
||||
> **Example:**
|
||||
> In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
> [screenshot]
|
||||
**Example:**
|
||||
|
||||
```
|
||||
In DarkMode, when scrollbar are displayed (for example on Companies page, with enough companies in the list), we see a blank square in the bottom right corner
|
||||
[screenshot]
|
||||
```
|
||||
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what the expected behavior is.
|
||||
|
||||
> **Example:**
|
||||
> The blank square should be transparent (invisible)
|
||||
**Example:**
|
||||
|
||||
```
|
||||
The blank square should be transparent (invisible)
|
||||
```
|
||||
|
||||
## Technical inputs
|
||||
|
||||
Operating System:
|
||||
**Example:**
|
||||
|
||||
Readest Version:
|
||||
|
||||
> **Example:**
|
||||
> Operating System: Android 14 (WebView 135.0)
|
||||
> Readest Version: 0.9.0
|
||||
> We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
> Probably fixable with CSS
|
||||
```
|
||||
- We are displaying custom scrollbars that disappear when the user is not scrolling. See ScrollWrapper.
|
||||
- Probably fixable with CSS
|
||||
```
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# Keep GitHub Actions up to date with GitHub's Dependabot...
|
||||
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
|
||||
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- '*' # Group all Actions updates into a single larger pull request
|
||||
schedule:
|
||||
interval: weekly
|
||||
@@ -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 +0,0 @@
|
||||
name: PR checks
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
rust_lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: '-C target-cpu=skylake'
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
- name: Install minimal stable with clippy and rustfmt
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
components: rustfmt, clippy
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-rust-lint-${{ runner.os }}
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev
|
||||
- name: Format check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo fmt --check
|
||||
- name: Clippy Check
|
||||
working-directory: apps/readest-app/src-tauri
|
||||
run: cargo clippy -p Readest --no-deps -- -D warnings
|
||||
|
||||
build_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# Turbopack persists its build cache here (turbopackFileSystemCacheForBuild).
|
||||
# The key is deterministic (no commit SHA) so every PR restores the same
|
||||
# entry — in practice the one `main` last saved, which is the only cache
|
||||
# sibling PRs can all see.
|
||||
- name: cache Turbopack build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: apps/readest-app/.next/cache
|
||||
key: turbo-build-web-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
turbo-build-web-${{ runner.os }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: install LuaJIT (for koplugin lint)
|
||||
run: sudo apt-get update && sudo apt-get install -y luajit
|
||||
|
||||
- name: run format check
|
||||
run: |
|
||||
pnpm format:check || (pnpm format && git diff && exit 1)
|
||||
|
||||
- name: run lint
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm lint
|
||||
|
||||
- name: build the web app
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm build-web && pnpm check:all
|
||||
|
||||
- name: cache playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web e2e tests
|
||||
id: web_e2e
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:e2e:web
|
||||
|
||||
- name: upload e2e report
|
||||
if: ${{ failure() && steps.web_e2e.outcome == 'failure' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/readest-app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
test_web_app:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-test-web-${{ runner.os }}
|
||||
|
||||
- name: install LuaJIT + busted (for koplugin tests)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# luajit — pnpm test:lua
|
||||
# luarocks/libsqlite3-dev — required to build lsqlite3complete
|
||||
sudo apt-get install -y luajit luarocks libsqlite3-dev
|
||||
# Install busted + the SQLite binding the LibraryStore specs use,
|
||||
# both pinned to Lua 5.1 (LuaJIT-compatible). System-wide install
|
||||
# so `luarocks --lua-version=5.1 path` (sourced by
|
||||
# scripts/test-koplugin.mjs) picks them up.
|
||||
sudo luarocks --lua-version=5.1 install busted
|
||||
sudo luarocks --lua-version=5.1 install lsqlite3complete
|
||||
|
||||
- name: cache playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = 'true' ]; then
|
||||
npx playwright install-deps chromium
|
||||
else
|
||||
npx playwright install --with-deps chromium
|
||||
fi
|
||||
|
||||
- name: run web tests
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:pr:web
|
||||
|
||||
- name: run koplugin tests
|
||||
working-directory: apps/readest-app
|
||||
run: pnpm test:lua
|
||||
|
||||
build_tauri_app:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: 'true'
|
||||
RUSTC_WRAPPER: sccache
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# The tauri tests run `next dev`, whose Turbopack cache lives in
|
||||
# `.next/dev/cache` (a different path from the `next build` cache).
|
||||
- name: cache Turbopack dev cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: apps/readest-app/.next/dev/cache
|
||||
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
turbo-dev-tauri-${{ runner.os }}-
|
||||
|
||||
- name: install Dependencies
|
||||
working-directory: apps/readest-app
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
|
||||
|
||||
- name: setup sccache
|
||||
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
|
||||
|
||||
- name: install Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
key: tauri-cargo
|
||||
cache-all-crates: 'true'
|
||||
|
||||
- name: Cache apt packages
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-tauri-${{ runner.os }}
|
||||
- name: install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libglib2.0-dev libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libsoup-3.0-dev xvfb
|
||||
|
||||
- name: run tauri tests
|
||||
working-directory: apps/readest-app
|
||||
run: xvfb-run pnpm test:pr:tauri
|
||||
+59
-359
@@ -5,121 +5,33 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
get-release:
|
||||
create-release:
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.get-release.outputs.release_id }}
|
||||
release_tag: ${{ steps.get-release.outputs.release_tag }}
|
||||
release_note: ${{ steps.get-release-notes.outputs.release_note }}
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
release_id: ${{ steps.create-release.outputs.result }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v3
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v3
|
||||
- name: get version
|
||||
run: echo "PACKAGE_VERSION=$(node -p "require('./apps/readest-app/package.json').version")" >> $GITHUB_ENV
|
||||
- name: get release
|
||||
id: get-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
- name: create release
|
||||
id: create-release
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
})
|
||||
core.setOutput('release_id', data.id);
|
||||
core.setOutput('release_tag', data.tag_name);
|
||||
- name: get release notes
|
||||
id: get-release-notes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const version = require('./apps/readest-app/package.json').version;
|
||||
const releaseNotesFileContent = fs.readFileSync('./apps/readest-app/release-notes.json', 'utf8');
|
||||
const releaseNotes = JSON.parse(releaseNotesFileContent).releases[version] || {};
|
||||
const notes = releaseNotes.notes || [];
|
||||
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
|
||||
console.log('Formatted release note:', releaseNote);
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-koreader-plugin:
|
||||
needs: get-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: create KOReader plugin zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
plugin_zip="Readest-${version}-1.koplugin.zip"
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
# Exclude dev-only artifacts from the published plugin zip:
|
||||
# scripts/ — i18n + build helpers
|
||||
# docs/ — design notes
|
||||
# spec/ — busted test suite
|
||||
# .busted — busted runner config
|
||||
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
|
||||
# for local builds.
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin \
|
||||
-x 'readest.koplugin/scripts/*' \
|
||||
'readest.koplugin/docs/*' \
|
||||
'readest.koplugin/spec/*' \
|
||||
'readest.koplugin/.busted'
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
return data.id
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
needs: create-release
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -127,192 +39,69 @@ jobs:
|
||||
matrix:
|
||||
config:
|
||||
- os: ubuntu-latest
|
||||
release: android
|
||||
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||
- os: ubuntu-22.04
|
||||
release: linux
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
release: linux
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
release: macos
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
args: '--target universal-apple-darwin'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
args: '--target x86_64-pc-windows-msvc --bundles nsis'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: aarch64
|
||||
rust_target: aarch64-pc-windows-msvc
|
||||
args: '--target aarch64-pc-windows-msvc --bundles nsis'
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
node-version: 24
|
||||
version: 9.14.4
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
run: |
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
- name: Create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_DEEPL_API_KEY=${{ secrets.NEXT_PUBLIC_DEEPL_API_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_APP_PLATFORM=tauri" >> .env.local
|
||||
cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: build and upload Android apks
|
||||
if: matrix.config.release == 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NDK_HOME: ${{ env.ANDROID_HOME }}/ndk/28.2.13676358
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
- name: Copy .env.local to apps/readest-app
|
||||
run: cp .env.local apps/readest-app/.env.local
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: matrix.config.os == 'ubuntu-latest'
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
pushd src-tauri/gen/android
|
||||
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
|
||||
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
|
||||
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
|
||||
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
|
||||
|
||||
popd
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universial_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universial_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
|
||||
echo "Uploading $universial_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
echo "Uploading signatures to GitHub release"
|
||||
pnpm tauri signer sign $universial_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Android release
|
||||
if: matrix.config.release == 'android'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
|
||||
arm64_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_arm64.apk"
|
||||
|
||||
universial_sig=$(cat Readest_${version}_universal.apk.sig)
|
||||
arm64_sig=$(cat Readest_${version}_arm64.apk.sig)
|
||||
|
||||
jq --arg url "$universial_apk_url" \
|
||||
--arg sig "$universial_sig" \
|
||||
'.platforms["android-universal"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
jq --arg url "$arm64_apk_url" \
|
||||
--arg sig "$arm64_sig" \
|
||||
'.platforms["android-arm64"] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
echo "Uploading updated latest.json to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
|
||||
|
||||
- name: Override tauri-cli with custom AppImage format (Linux)
|
||||
if: matrix.config.release == 'linux'
|
||||
run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
|
||||
|
||||
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
|
||||
if: matrix.config.release != 'android'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: 'true'
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -321,118 +110,29 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
NODE_OPTIONS: '--max-old-space-size=8192'
|
||||
with:
|
||||
projectPath: apps/readest-app
|
||||
# On Linux, build with the Rust `cargo tauri` CLI installed in the
|
||||
# step above so the new (truly-portable AppImage) bundler is used.
|
||||
# Without this, tauri-action falls back to the npm @tauri-apps/cli.
|
||||
tauriScript: ${{ matrix.config.release == 'linux' && 'cargo tauri' || '' }}
|
||||
releaseId: ${{ needs.get-release.outputs.release_id }}
|
||||
releaseBody: ${{ needs.get-release.outputs.release_note }}
|
||||
args: ${{ matrix.config.args || '' }}
|
||||
releaseId: ${{ needs.create-release.outputs.release_id }}
|
||||
args: ${{ matrix.config.os == 'macos-latest' && '--target universal-apple-darwin' || '' }}
|
||||
|
||||
- name: upload release notes to GitHub release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Uploading release notes to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
|
||||
|
||||
- name: build and upload portable binaries (Windows only)
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Building Portable Binaries"
|
||||
pushd apps/readest-app/
|
||||
echo "NEXT_PUBLIC_PORTABLE_APP=true" >> .env.local
|
||||
pnpm tauri build ${{ matrix.config.args }}
|
||||
|
||||
popd
|
||||
echo "Uploading Portable Binaries"
|
||||
arch=${{ matrix.config.arch }}
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
else
|
||||
echo "Unknown architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exe_file="target/${{ matrix.config.rust_target }}/release/readest.exe"
|
||||
# Browsers on Windows won't download zip files that contain exe files
|
||||
# so upload the exe files instead. This is totally stupid.
|
||||
# powershell.exe -Command "Compress-Archive -Path $exe_file -DestinationPath $bin_file -Force"
|
||||
cp $exe_file $bin_file
|
||||
|
||||
echo "Uploading $bin_file to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file --clobber
|
||||
|
||||
echo "Signing portable binary"
|
||||
pushd apps/readest-app/
|
||||
pnpm tauri signer sign "../../$bin_file"
|
||||
popd
|
||||
echo "Uploading signature to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $bin_file.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Windows portable release
|
||||
if: matrix.config.os == 'windows-latest'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
arch=${{ matrix.config.arch }}
|
||||
|
||||
if [ "$arch" = "x86_64" ]; then
|
||||
bin_file="Readest_${version}_x64-portable.exe"
|
||||
platform_key="windows-x86_64-portable"
|
||||
elif [ "$arch" = "aarch64" ]; then
|
||||
bin_file="Readest_${version}_arm64-portable.exe"
|
||||
platform_key="windows-aarch64-portable"
|
||||
else
|
||||
echo "Unknown architecture: $arch"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
portable_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/$bin_file"
|
||||
portable_sig=$(cat $bin_file.sig)
|
||||
|
||||
jq --arg url "$portable_url" \
|
||||
--arg sig "$portable_sig" \
|
||||
--arg key "$platform_key" \
|
||||
'.platforms[$key] = {signature: $sig, url: $url}' latest.json > tmp.$$.json && mv tmp.$$.json latest.json
|
||||
|
||||
echo "Uploading updated latest.json to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} latest.json --clobber
|
||||
|
||||
upload-to-r2:
|
||||
needs: [get-release, build-tauri]
|
||||
publish-release:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [create-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: publish release
|
||||
id: publish-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
release_id: ${{ needs.create-release.outputs.release_id }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
@@ -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,74 +0,0 @@
|
||||
name: Upload Release Assets to R2
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag name (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-to-r2:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 3
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
RELEASE_R2_BUCKET: readest-releases
|
||||
RELEASE_R2_ACCOUNT_ID: ${{ secrets.RELEASE_R2_ACCOUNT_ID }}
|
||||
RELEASE_R2_ACCESS_KEY_ID: ${{ secrets.RELEASE_R2_ACCESS_KEY_ID }}
|
||||
RELEASE_R2_SECRET_ACCESS_KEY: ${{ secrets.RELEASE_R2_SECRET_ACCESS_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Download release assets
|
||||
run: |
|
||||
gh release download "${{ inputs.tag }}" --repo readest/readest --dir ./release-assets
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install rclone
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y rclone
|
||||
|
||||
- name: Configure rclone
|
||||
run: |
|
||||
mkdir -p ~/.config/rclone
|
||||
cat > ~/.config/rclone/rclone.conf <<EOF
|
||||
[r2]
|
||||
type = s3
|
||||
provider = Cloudflare
|
||||
access_key_id = $RELEASE_R2_ACCESS_KEY_ID
|
||||
secret_access_key = $RELEASE_R2_SECRET_ACCESS_KEY
|
||||
endpoint = https://${RELEASE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com
|
||||
EOF
|
||||
|
||||
- name: Modify latest.json download URLs
|
||||
run: |
|
||||
GITHUB_BASE_URL="https://github.com/readest/readest/releases/download"
|
||||
READEST_BASE_URL="https://download.readest.com/releases"
|
||||
sed -i "s#${GITHUB_BASE_URL}#${READEST_BASE_URL}#g" ./release-assets/latest.json
|
||||
|
||||
- name: Upload to R2
|
||||
run: |
|
||||
mkdir releases
|
||||
mv ./release-assets/latest.json releases
|
||||
mv ./release-assets/release-notes.json releases
|
||||
rclone copy ./release-assets r2:${RELEASE_R2_BUCKET}/releases/${{ inputs.tag }}/
|
||||
rclone copy ./releases r2:${RELEASE_R2_BUCKET}/releases/
|
||||
|
||||
- name: Upload successful
|
||||
if: success()
|
||||
run: echo "Upload completed successfully"
|
||||
@@ -1,23 +0,0 @@
|
||||
name: Deploy to vercel on merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-comment: false
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
-19
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
# Contribution Guidelines
|
||||
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
Basically you need to install or update the following development tools:
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
|
||||
```bash
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
|
||||
### Getting Started
|
||||
|
||||
To get started with Readest, follow these steps to clone and build the project.
|
||||
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
|
||||
To confirm that all dependencies are correctly installed, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm tauri info
|
||||
```
|
||||
|
||||
This command will display information about the installed Tauri dependencies and configuration on your platform. Note that the output may vary depending on the operating system and environment setup. Please review the output specific to your platform for any potential issues.
|
||||
|
||||
For Windows targets, “Build Tools for Visual Studio 2022” (or a higher edition of Visual Studio) and the “Desktop development with C++” workflow must be installed. For Windows ARM64 targets, the “VS 2022 C++ ARM64 build tools” and "C++ Clang Compiler for Windows" components must be installed. And make sure `clang` can be found in the path by adding `C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Tools\Llvm\x64\bin` for example in the environment variable `Path`.
|
||||
|
||||
#### 4. Build for Development
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
| `pnpm dev-web` | Starts the development server for the web app only |
|
||||
| `pnpm build-web` | Builds the web app |
|
||||
|
||||
Recommended Visual Studio Code plugins for development:
|
||||
|
||||
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
|
||||
- VS Code ESLint extension (dbaeumer.vscode-eslint)
|
||||
- Biome - Code formatter and linter (biomejs.biome)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Check that your code follows the project's style guidelines by running:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Please also make a manual, functional test of your changes. When all that's done, it's time to file a pull request to upstream and fill out the title and body appropriately.
|
||||
|
||||
## Credits
|
||||
|
||||
This documented was inspired by the contributing guidelines for [cloudflare/wrangler2](https://github.com/cloudflare/wrangler2/blob/main/CONTRIBUTING.md).
|
||||
Generated
-10826
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
|
||||
@@ -1,26 +1,20 @@
|
||||
<div align="center">
|
||||
<a href="https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank">
|
||||
<img src="https://github.com/readest/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
|
||||
<img src="https://github.com/chrox/readest/blob/main/apps/readest-app/src-tauri/icons/icon.png?raw=true" alt="Readest Logo" width="20%" />
|
||||
</a>
|
||||
<h1>Readest</h1>
|
||||
<br>
|
||||
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 16](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to deliver a smooth, cross-platform experience across macOS, Windows, Linux, Android, iOS, and the Web.
|
||||
[Readest][link-website] is an open-source ebook reader designed for immersive and deep reading experiences. Built as a modern rewrite of [Foliate](https://github.com/johnfactotum/foliate), it leverages [Next.js 15](https://github.com/vercel/next.js) and [Tauri v2](https://github.com/tauri-apps/tauri) to offer a seamless cross-platform experience on macOS, Windows, Linux and Web, with support for mobile platforms coming soon.
|
||||
|
||||
[![Website][badge-website]][link-website]
|
||||
[![Web App][badge-web-app]][link-web-readest]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
<br>
|
||||
[![Discord][badge-discord]][link-discord]
|
||||
[![Reddit][badge-reddit]][link-reddit]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Language Coverage][badge-language-coverage]][link-locales]
|
||||
[![Donate][badge-donate]][link-donate]
|
||||
[![OS][badge-platforms]][link-website]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
[![][badge-hellogithub]][link-hellogithub]
|
||||
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,14 +24,13 @@
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#support">Support</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/readest_landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -45,85 +38,71 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
<div align="left">🛠 Building</div>
|
||||
<div align="left">🔄 Planned</div>
|
||||
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
|
||||
| **Sync Across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
- **Node.js** and **pnpm** for Next.js development
|
||||
- **Rust** and **Cargo** for Tauri development
|
||||
- **Rust and Cargo** for Tauri development
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
```bash
|
||||
nvm install v24
|
||||
nvm use v24
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
@@ -135,18 +114,18 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
git clone https://github.com/chrox/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
git submodule update --init --recursive
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
### 3. Verify Dependencies Installation
|
||||
@@ -164,201 +143,52 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
# preview with OpenNext build for the Web app
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri android dev --host
|
||||
```
|
||||
|
||||
For iOS:
|
||||
|
||||
```bash
|
||||
# Set up the iOS environment (run once)
|
||||
pnpm tauri ios init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri ios dev --host
|
||||
```
|
||||
|
||||
### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
## Contributing
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
with all the necessary dependencies:
|
||||
|
||||
```bash
|
||||
nix develop ./ops # enter a dev shell for the web app
|
||||
nix develop ./ops#ios # enter a dev shell for the ios app
|
||||
nix develop ./ops#android # enter a dev shell for the android app
|
||||
```
|
||||
|
||||
### 7. More information
|
||||
|
||||
Please check the [wiki][link-gh-wiki] of this project for more information on development.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Readest 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.
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please review our contributing guidelines before you start.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
<!---
|
||||
npx contributor-faces --exclude "*bot*" --limit 100"
|
||||
--->
|
||||
|
||||
<a href="https://github.com/readest/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
[//]: contributor-faces
|
||||
|
||||
## Support
|
||||
<a href="https://github.com/chrox"><img src="https://avatars.githubusercontent.com/u/751535?v=4" title="chrox" width="50" height="50"></a>
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
[//]: contributor-faces
|
||||
|
||||
## License
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following libraries and frameworks are used in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
|
||||
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
Readest is distributed under the AGPL-3.0 License. See the [LICENSE](<(LICENSE)>) file for details.
|
||||
|
||||
---
|
||||
|
||||
<div align="center" style="color: gray;">Happy reading with Readest!</div>
|
||||
|
||||
[badge-website]: https://img.shields.io/badge/website-readest.com-orange
|
||||
[badge-web-app]: https://img.shields.io/badge/read%20online-web.readest.com-orange
|
||||
[badge-license]: https://img.shields.io/github/license/readest/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/readest/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/platforms-macOS%2C%20Windows%2C%20Linux%2C%20Android%2C%20iOS%2C%20Web%2C%20PWA-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/readest/readest?color=blue
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/readest/readest?color=blue
|
||||
[badge-license]: https://img.shields.io/github/license/chrox/readest?color=teal
|
||||
[badge-release]: https://img.shields.io/github/release/chrox/readest?color=green
|
||||
[badge-platforms]: https://img.shields.io/badge/OS-macOS%2C%20Windows%2C%20Linux%2C%20Web-green
|
||||
[badge-last-commit]: https://img.shields.io/github/last-commit/chrox/readest?color=green
|
||||
[badge-commit-activity]: https://img.shields.io/github/commit-activity/m/chrox/readest
|
||||
[badge-discord]: https://img.shields.io/discord/1314226120886976544?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square
|
||||
[badge-hellogithub]: https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=8a5b6ade2aee461a8bd94e59200682a7&claim_uid=eRLUbPOy2qZtDgw&theme=small
|
||||
[badge-donate]: https://donate.readest.com/badge.svg
|
||||
[badge-deepwiki]: https://deepwiki.com/badge.svg
|
||||
[badge-reddit]: https://img.shields.io/reddit/subreddit-subscribers/readest?style=flat&logo=reddit&color=F37E41
|
||||
[badge-language-coverage]: https://img.shields.io/badge/coverage-53%25%20population%20🌍-green
|
||||
[link-donate]: https://donate.readest.com/?tickers=btc%2Ceth%2Csol%2Cusdc
|
||||
[link-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-macos-appstore]: https://apps.apple.com/app/apple-store/id6738622779?pt=127463130&ct=github&mt=8
|
||||
[link-website]: https://readest.com?utm_source=github&utm_medium=referral&utm_campaign=readme
|
||||
[link-flathub]: https://flathub.org/en/apps/com.bilingify.readest
|
||||
[link-web-readest]: https://web.readest.com
|
||||
[link-gh-releases]: https://github.com/readest/readest/releases
|
||||
[link-gh-commits]: https://github.com/readest/readest/commits/main
|
||||
[link-gh-pulse]: https://github.com/readest/readest/pulse
|
||||
[link-gh-wiki]: https://github.com/readest/readest/wiki
|
||||
[link-discord]: https://discord.gg/gntyVNk3BJ
|
||||
[link-gh-releases]: https://github.com/chrox/readest/releases
|
||||
[link-gh-commits]: https://github.com/chrox/readest/commits/main
|
||||
[link-gh-pulse]: https://github.com/chrox/readest/pulse
|
||||
[link-discord]: https://discord.gg/jb2nzDts
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
|
||||
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
|
||||
[link-reddit]: https://reddit.com/r/readest/
|
||||
|
||||
-142
@@ -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,84 +0,0 @@
|
||||
# Readest Project Memory
|
||||
|
||||
## Key Reference Documents
|
||||
- [Bug Fixing Patterns](bug-patterns.md) - Common bug categories, root causes, and fix strategies
|
||||
- [CSS & Style Fixes](css-style-fixes.md) - EPUB CSS override patterns and the style.ts pipeline
|
||||
- [TTS Fixes](tts-fixes.md) - Text-to-Speech architecture and bug patterns
|
||||
- [Layout & UI Fixes](layout-ui-fixes.md) - Safe insets, z-index, platform-specific UI issues
|
||||
- [Platform Compat Fixes](platform-compat-fixes.md) - Android, iOS, Linux, macOS platform-specific bugs
|
||||
- [Annotator & Reader Fixes](annotator-reader-fixes.md) - Highlight, selection, accessibility bugs
|
||||
|
||||
## Paginator Scroll Knowledge
|
||||
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
|
||||
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
|
||||
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
|
||||
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
|
||||
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
|
||||
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
|
||||
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
|
||||
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
|
||||
|
||||
## Critical Files (Most Bug-Prone)
|
||||
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
|
||||
- `packages/foliate-js/paginator.js` - Page layout, image sizing, backgrounds
|
||||
- `src/services/tts/TTSController.ts` - TTS state machine, section tracking
|
||||
- `src/hooks/useSafeAreaInsets.ts` - Safe area inset management
|
||||
- `src/app/reader/components/FoliateViewer.tsx` - Reader view orchestration
|
||||
- `src/app/reader/components/annotator/Annotator.tsx` - Annotation lifecycle
|
||||
|
||||
## Sync Notes
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
|
||||
|
||||
## Testing
|
||||
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix
|
||||
|
||||
## Build & Vendoring
|
||||
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
|
||||
|
||||
## Platform Compat
|
||||
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
|
||||
|
||||
## Feature Notes
|
||||
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
|
||||
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / keyboard arrow navigation design, key files, and pitfalls
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
|
||||
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
|
||||
|
||||
## Reader UI Fixes
|
||||
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
|
||||
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
|
||||
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
|
||||
|
||||
## Library Fixes
|
||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
|
||||
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
|
||||
- Style overrides: `getLayoutStyles()` (always), `getColorStyles()` (when overriding color)
|
||||
- `transformStylesheet()` does regex-based EPUB CSS rewriting at load time
|
||||
- TTS uses independent section tracking (`#ttsSectionIndex`) decoupled from view
|
||||
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
|
||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
- [Always rebase before PR](feedback_pr_rebase.md) — rebase onto origin/main before creating PRs
|
||||
- [New branch per PR](feedback_pr_new_branch.md) — always create a fresh branch from main for each new PR/issue
|
||||
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — always upgrade from the project's .claude/skills/gstack, not global
|
||||
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never use `(?<=)` or `(?<!)` in JS/TS; build check rejects them
|
||||
- [Use worktree](feedback_use_worktree.md) — never `git worktree add` directly; always `pnpm worktree:new` before PR review, issue fix, or feature work
|
||||
- [en/translation.json holds ONLY plural variants + proper nouns](feedback_en_plurals_manual.md) — non-plural strings stay out (defaultValue: key is the en source); plural strings (`_('...', { count })`) need hand-added `_one`/`_other` entries or the singular renders as "1 days"
|
||||
- [Never push on every change](feedback_dont_push_every_change.md) — hold pushes during active bug iteration; commit locally only until user confirms or work hits a clean done-state
|
||||
- [No test seams in production code](feedback_no_test_seams_in_prod.md) — production must never import or call `__reset*ForTests`; cross-module test resets belong in the test file's beforeEach/afterEach
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
name: android-image-callout-freeze
|
||||
description: "Android WebView native long-press image callout collides with app touch handlers and freezes the app; reusable `.no-context-menu` fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 50bec34f-7090-4bf4-a194-9bf4029527bf
|
||||
---
|
||||
|
||||
Recurring Android-only freeze: long-pressing an `<img>` triggers the WebView's
|
||||
native image callout (context menu / drag / magnifier) which collides with the
|
||||
app's own touch handlers (long-press multi-select, or pinch/pan) and freezes the
|
||||
whole app until restart.
|
||||
|
||||
**Root cause:** `-webkit-touch-callout: none` does NOT inherit, so a
|
||||
`.no-context-menu` class on a *container* never reaches descendant images.
|
||||
|
||||
**Fix:** the `.no-context-menu img, .no-context-menu a` rule in
|
||||
`src/styles/globals.css` (sets `-webkit-touch-callout: none; -webkit-user-drag:
|
||||
none; user-select: none`). Apply the `no-context-menu` class to an *ancestor* of
|
||||
the image so the descendant rule reaches it. Harmless on desktop
|
||||
(`-webkit-touch-callout` is a no-op there; right-click-save still works).
|
||||
|
||||
Occurrences so far:
|
||||
- Book covers on the bookshelf — PR #4345 (`BookshelfItem.tsx`, gated on
|
||||
`appService?.isMobileApp`; added the `.no-context-menu img` rule).
|
||||
- Image preview / zoom viewer — issue #4420, `ImageViewer.tsx` root container
|
||||
(applied unconditionally — no selectable text there, so no need to gate).
|
||||
|
||||
**How to apply:** when a new "Android freezes on long-press of an image" report
|
||||
comes in, find the `<img>` and put `no-context-menu` on a containing element.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Annotator & Reader Fixes Reference
|
||||
|
||||
## Annotation System Architecture
|
||||
|
||||
### Key Components
|
||||
- `Annotator.tsx` - Annotation lifecycle, popup display, style/color management
|
||||
- `AnnotationRangeEditor.tsx` - Drag handles for adjusting selection range
|
||||
- `MagnifierLoupe.tsx` - Magnifying glass during handle drag (mobile only)
|
||||
- `useTextSelector.ts` - Text selection detection and processing
|
||||
- `useAnnotationEditor.ts` - Editing existing annotations
|
||||
- `useInstantAnnotation.ts` - Creating new annotations on selection
|
||||
|
||||
### Highlight Rendering
|
||||
- Highlights rendered by foliate-js `Overlayer` (SVG overlayer in paginator shadow DOM, not iframe)
|
||||
- Each view in multiview paginator has its own `Overlayer` instance with unique clipPath ID
|
||||
- `Overlayer.add()` stores range + draw function; `redraw()` recalculates positions from stored ranges
|
||||
- Colors stored as color names mapped to custom hex via `globalReadSettings.customHighlightColors`
|
||||
- Sidebar uses `color-mix()` CSS function with custom colors, not Tailwind utility classes (#3273)
|
||||
- Rounded highlight style supported via `vertical` option passed to overlayer (#3208)
|
||||
|
||||
### Multiview Overlayer Pitfalls
|
||||
- **Duplicate SVG IDs**: Each overlayer creates `<clipPath>` for loupe hole — IDs MUST be unique per instance or `url(#id)` resolves to wrong element, clipping everything
|
||||
- **docLoadHandler scope**: `FoliateViewer.tsx` re-adds annotations on `load` event — MUST filter by `detail.index` (loaded section), not re-add ALL annotations (overwrites drag edits)
|
||||
- **MagnifierLoupe lifecycle**: Don't destroy/recreate loupe on every drag tick — `hideLoupe()` should only run on unmount, `showLoupe()` fast path updates position only
|
||||
- **Stale closures in useTextSelector**: `getProgress()` must be called inside callbacks, not captured at hook top-level (useFoliateEvents deps are `[view]` only)
|
||||
|
||||
## Fix History
|
||||
|
||||
| Issue | Problem | Root Cause | Fix |
|
||||
|-------|---------|------------|-----|
|
||||
| #3286 | Selection stuck on first annotation | `initializedRef` guard blocked re-computation | Remove guard, consolidate style/color effects |
|
||||
| #3273 | Custom colors not in sidebar | Hardcoded Tailwind classes | Use inline `style` with `color-mix()` |
|
||||
| #3234 | Letter-by-letter selection on mobile | No word boundary snapping | Add `snapRangeToWords()` using `Intl.Segmenter` |
|
||||
| #3208 | Hard rectangular highlights | No border radius support | Pass `vertical` option, update foliate-js |
|
||||
| #3002 | Can't see text under finger | No magnification UI | New `MagnifierLoupe` component using `view.renderer.showLoupe()` |
|
||||
| #3082 | No page numbers on annotations | `pageNumber` field missing | Add `pageNumber` to BookNote type, compute on create |
|
||||
| #3225 | Android tools unresponsive | Premature `makeSelection()` call | Remove premature re-selection in Android path |
|
||||
|
||||
## Common Annotation Bugs
|
||||
|
||||
### Selection Issues
|
||||
- **Word snapping**: Uses `Intl.Segmenter` with `granularity: 'word'` to snap selection to word boundaries
|
||||
- **Android re-selection**: Don't call `makeSelection(sel, index, true)` immediately on pointer-up; let the popup flow complete
|
||||
- **Range editor handles**: Remove `initializedRef` guards that prevent re-computation when switching annotations
|
||||
|
||||
### Color/Style Issues
|
||||
- **Custom colors in sidebar**: Use inline `style={{ backgroundColor: 'color-mix(...)' }}` not Tailwind classes
|
||||
- **Style synchronization**: Consolidate `selectedStyle` and `selectedColor` into one `useEffect`
|
||||
- **Switching annotations**: Must call `setShowAnnotPopup(false)` and `setEditingAnnotation(null)` before setting up new annotation
|
||||
|
||||
## Reader/Content Fixes
|
||||
|
||||
### Progress Display
|
||||
- Use physical `view.renderer.page` and `view.renderer.pages` for page counts (#3213, #3200)
|
||||
- Last page shows 100% by fixing boundary condition (#3383)
|
||||
- FB2 subsections need special handling for progress (#3136)
|
||||
|
||||
### Translation View (#3078)
|
||||
- Problem: Page jumps back during full-text translation
|
||||
- Root cause: DOM mutations from sequential translation insertions cause paginator relayout
|
||||
- Fix: Batch DOM updates with 50ms timer, use bounded concurrent queue (max 5), show loading overlay
|
||||
|
||||
### TOC Navigation (#3124)
|
||||
- Problem: Expanding TOC chapter scrolls back to current chapter
|
||||
- Fix: Only scroll-into-view on navigation, not on expand/collapse
|
||||
|
||||
## Accessibility (a11y) Fixes
|
||||
|
||||
### Screen Reader (TalkBack) Support
|
||||
- **Page indicator updates** (#2276): Add focus handlers on `<p>` elements that call `view.goTo(cfi)` to update position
|
||||
- **Navigation buttons** (#3036): Always show prev/next buttons when screen reader active; `PageNavigationButtons.tsx`
|
||||
- **Dropdown menus** (#3035): Use `DropdownContext` with overlay dismiss instead of blur-based closing
|
||||
|
||||
### Dropdown Architecture for a11y
|
||||
- `DropdownContext` (`src/context/DropdownContext.tsx`) manages which dropdown is open globally
|
||||
- Uses `useId()` for unique identification
|
||||
- One dropdown open at a time
|
||||
- `<Overlay>` for dismissal (tap/click outside) instead of `onBlur`
|
||||
- `<details>` element with `open={isOpen}` for semantic structure
|
||||
- No auto-focus-first-item (conflicts with TalkBack)
|
||||
|
||||
## E-ink Readability
|
||||
- Use `not-eink:` Tailwind variant for colors and opacity (#3258)
|
||||
- Don't use `text-primary` (blue) or low opacity on e-ink
|
||||
- Highlights use foreground color in dark mode e-ink (#3299)
|
||||
|
||||
## Key Utility Functions
|
||||
- `snapRangeToWords()` in `src/utils/sel.ts` - Word boundary snapping
|
||||
- `handleAccessibilityEvents()` in `src/utils/a11y.ts` - Screen reader focus handling
|
||||
- `color-mix()` CSS function for custom highlight colors with opacity
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
name: booknote-view-autoscroll-4352
|
||||
description: "Annotation/bookmark list (BooknoteView) auto-scroll-to-nearest regression after virtualization (#4352) and its TOCView-mirroring fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: bda988b9-28ec-450f-874e-ee9c104f7603
|
||||
---
|
||||
|
||||
After #4352 virtualized `BooknoteView` (sidebar annotations/bookmarks list,
|
||||
`src/app/reader/components/sidebar/BooknoteView.tsx`), the list stopped
|
||||
auto-scrolling to the nearest annotation for the current reading position (it
|
||||
stranded at the top showing Chapter 1). #4352 replaced the per-item
|
||||
`useScrollToItem` with a single `virtuosoRef.scrollToIndex`, but missed the
|
||||
machinery `TOCView` already had. Two distinct failure paths:
|
||||
|
||||
1. **Reload (annotations tab active at load; progress arrives AFTER mount):** the
|
||||
OverlayScrollbars `initialized` callback (deferred init) resets the wrapped
|
||||
viewport `scrollTop` to 0, clobbering the scroll; the `lastScrolledCfiRef`
|
||||
guard then blocks any retry. Fix = re-apply `scrollToIndex` to the *current*
|
||||
nearest index inside the `initialized` callback, read via a **ref**
|
||||
(`nearestIndexRef`) because that callback is the mount-time closure. Double
|
||||
rAF (settle the reset, then re-assert once rows are measured).
|
||||
2. **Tab-switch (open panel while reading; progress KNOWN at mount):** firing a
|
||||
`scrollToIndex` synchronously on the freshly mounted, unmeasured list either
|
||||
no-ops (`behavior:'smooth'`) or **wedges Virtuoso into rendering nothing**
|
||||
(`behavior:'auto'`). Fix = mount Virtuoso *natively* centered via
|
||||
`initialTopMostItemIndex` + an `initialScrollHandledRef` gate so the scroll
|
||||
effect SKIPS that first jump. This is exactly TOCView's design.
|
||||
|
||||
The fix mirrors [[toc-expand-and-autoscroll]] (same OverlayScrollbars-resets-
|
||||
scrollTop + Virtuoso-lands-short-on-unmeasured-rows pattern). Test:
|
||||
`src/__tests__/components/BooknoteView.test.tsx` stubs Virtuoso (spy-able
|
||||
`scrollToIndex`, captures `initialTopMostItemIndex`) and captures the mount-time
|
||||
`initialized` callback — forcing a ref-based fix (modeled on `TOCView.test.tsx`).
|
||||
|
||||
**Dev-server verification gotchas (cost hours here):**
|
||||
- The `localhost:3000` dev server was running from a *different worktree*
|
||||
(`/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed`), not the main checkout.
|
||||
Edits weren't compiled until copied into that worktree's path. Check
|
||||
`ps aux | grep next-server` for the serving cwd. Book data is per-origin
|
||||
(OPFS/IndexedDB on localhost:3000) so you can't verify on another port.
|
||||
- After ~10 rapid file syncs, **Fast Refresh corrupts the mounted tab's state**
|
||||
— identical code that worked started rendering 0 items. A *brand-new tab*
|
||||
(close the old one) renders correctly. Always verify in a fresh tab.
|
||||
- Chrome MCP `javascript_tool` querying `.booknote-item` count catches Virtuoso
|
||||
mid-render (returns 0 even when it later paints fine). Trust the *screenshot*
|
||||
(the painted frame), not a synchronous DOM count, for virtualized lists.
|
||||
@@ -1,133 +0,0 @@
|
||||
# Bug Fixing Patterns & Strategies
|
||||
|
||||
## Common Root Cause Categories
|
||||
|
||||
### 1. Overly Broad CSS Selectors
|
||||
**Pattern:** A CSS rule targets too many elements, causing unintended visual side effects.
|
||||
**Examples:**
|
||||
- `hr { mix-blend-mode: multiply }` applied to ALL hr elements instead of only decorative ones (#3086)
|
||||
- `p img { mix-blend-mode }` applied to block images, not just inline (#3112)
|
||||
- `svg, img { height: auto; width: auto }` overrode explicit HTML width/height attributes (#3274)
|
||||
- Background-color override applied unconditionally instead of only when user enabled color override (#3316)
|
||||
|
||||
**Fix Strategy:** Narrow selectors with class qualifiers (`.background-img`, `.has-text-siblings`) or attribute pseudo-selectors (`:where(:not([width]))`). Check if the rule should be conditional on a user setting.
|
||||
|
||||
### 2. Conditional vs Unconditional Style Overrides
|
||||
**Pattern:** CSS rules meant for "Override Book Color/Layout" mode are placed in the always-active stylesheet.
|
||||
**Examples:**
|
||||
- Calibre `.calibre { color: unset }` was in `getLayoutStyles()` instead of `getColorStyles()` (#3448)
|
||||
- Image background-color override applied without checking `overrideColor` flag (#3316, #3377)
|
||||
|
||||
**Fix Strategy:** Move rules to the correct conditional block: `getColorStyles()` for color overrides, `getLayoutStyles()` for layout overrides. Check the `overrideColor`/`overrideLayout` flags.
|
||||
|
||||
### 3. Missing EPUB Stylesheet Transformations
|
||||
**Pattern:** EPUB stylesheets contain CSS that conflicts with app functionality.
|
||||
**Examples:**
|
||||
- `user-select: none` prevents text selection (#3370) -> regex replace in `transformStylesheet()`
|
||||
- `font-family: serif/sans-serif` on body bypasses user font (#3334) -> detect and unset
|
||||
- Hardcoded Calibre backgrounds persist in dark mode (#3448) -> unset in color override
|
||||
|
||||
**Fix Strategy:** Add regex-based transformation passes in `transformStylesheet()` in `style.ts`.
|
||||
|
||||
### 4. Stale State / Refs Not Reset
|
||||
**Pattern:** A `useRef` or state variable is set once and never properly reset, blocking re-entry.
|
||||
**Examples:**
|
||||
- TTS `ttsOnRef` prevented restarting TTS from a new location (#3292)
|
||||
- `initializedRef` in AnnotationRangeEditor prevented handle position updates (#3286)
|
||||
- `view.tts` not nulled on shutdown prevented clean TTS restart (#3400)
|
||||
- TTS safety timeout fired after pause, advancing to next sentence (#3244)
|
||||
|
||||
**Fix Strategy:** Check all refs/guards in the affected flow. Ensure cleanup in shutdown/unmount. Remove overly aggressive guards that prevent re-entry.
|
||||
|
||||
### 5. Platform API Differences
|
||||
**Pattern:** A Web API behaves differently or is unavailable on certain platforms.
|
||||
**Examples:**
|
||||
- `navigator.getGamepads()` returns null on older Android WebView (#3245)
|
||||
- `CompressionStream` unavailable on some Android versions (#3255)
|
||||
- `btoa()` throws on non-ASCII characters (#3436)
|
||||
- View Transitions API unsupported in WebKitGTK/Linux (#3417)
|
||||
- `document.startViewTransition()` crashes on Linux
|
||||
|
||||
**Fix Strategy:** Always check API availability before use. Add fallback paths. Use feature detection, not platform detection when possible.
|
||||
|
||||
### 6. Safe Area Inset Issues
|
||||
**Pattern:** UI elements overlap system bars (status bar, navigation bar, notch) on mobile.
|
||||
**Examples:**
|
||||
- Zoom controls behind status bar (#3426)
|
||||
- Android navigation bar overlap (#3466)
|
||||
- iPad sidebar insets incorrect (#3395)
|
||||
- Reader page layout jump after system UI change (#3469)
|
||||
|
||||
**Fix Strategy:** Use `gridInsets` and `statusBarHeight` from `useSafeAreaInsets`. Use `env(safe-area-inset-*)` CSS functions. Call `onUpdateInsets()` after system UI visibility changes. See `docs/safe-area-insets.md`.
|
||||
|
||||
### 7. Z-Index Layering Issues
|
||||
**Pattern:** Interactive elements rendered behind other layers, becoming unclickable.
|
||||
**Examples:**
|
||||
- Navigation buttons invisible on mobile (#3201) -> added `z-10`
|
||||
- Annotation nav bar too prominent (#3386) -> reduced from `z-30` to `z-10`
|
||||
- Page nav buttons behind TTS control (#3184)
|
||||
|
||||
**Fix Strategy:** Check z-index ordering. Use minimum necessary z-index. Reference the z-index hierarchy in the codebase.
|
||||
|
||||
### 8. Event Handling Race Conditions
|
||||
**Pattern:** Timing issues between pointer events, native menus, and React state updates.
|
||||
**Examples:**
|
||||
- macOS context menu steals pointer event loop (#3324) -> 100ms setTimeout delay
|
||||
- Traffic light buttons flicker due to timeout race (#3488, #3129)
|
||||
- Android tool buttons unresponsive due to premature re-selection (#3225)
|
||||
|
||||
**Fix Strategy:** Add small delays before native menu calls. Check event state machine consistency. Remove premature re-triggers on Android.
|
||||
|
||||
### 9. foliate-js Rendering Issues
|
||||
**Pattern:** Bugs in the lower-level EPUB renderer (paginator.js, epub.js).
|
||||
**Examples:**
|
||||
- Image size not constrained in double-page mode (#3432)
|
||||
- Background not shown in scrolled mode (#3344)
|
||||
- Section content cached incorrectly after mode switch (#3242, #3206)
|
||||
- Swipe sensitivity too low for non-animated paging (#3310)
|
||||
|
||||
**Fix Strategy:** Check both `columnize()` and `scrolled()` code paths in paginator.js. Verify CSS variables (`--available-width`, `--available-height`) are computed correctly. Test in both paginated and scrolled modes.
|
||||
|
||||
### 10. Progress/Navigation Calculation Errors
|
||||
**Pattern:** Page counts, progress percentages, or position tracking are wrong.
|
||||
**Examples:**
|
||||
- Progress shows 99.9% at last page (#3383) -> boundary condition
|
||||
- Pages left shows estimated instead of physical count (#3213, #3200)
|
||||
- FB2 subsection progress wrong (#3136) -> nested structure not handled
|
||||
- TOC auto-scrolls on expand (#3124) -> scroll-into-view triggered too broadly
|
||||
|
||||
**Fix Strategy:** Use physical `view.renderer.page`/`view.renderer.pages` instead of estimated section metadata. Check boundary conditions (0-indexed vs 1-indexed, inclusive vs exclusive).
|
||||
|
||||
### 11. Debounced State Stale on User-Initiated Layout Change
|
||||
**Pattern:** A scroll/resize handler is debounced for performance, but during the debounce window any code path that re-runs layout based on saved state (e.g. `#anchor`, `#primaryIndex`) sees stale values.
|
||||
**Example:**
|
||||
- Scrolled-mode toggle reverted to previous chapter (#3987): the paginator's scroll handler is debounced 250 ms, so toggling `flow=scrolled → flow=paginated` within that window made `render() → scrollToAnchor(#anchor)` restore the anchor from before the user scrolled into the next section. Both `#anchor` and `#primaryIndex` were stale together, sending the position back.
|
||||
|
||||
**Fix Strategy:** When an external trigger forces a re-render (here, `setAttribute('flow', ...)`), flush the debounced state synchronously *before* changing the layout. In paginator.js this means overriding `setAttribute` and calling `#detectPrimaryView()` + `#getVisibleRange()` while `this.scrolled` is still true.
|
||||
|
||||
### 12. Multiview Paginator Side Effects
|
||||
**Pattern:** The multiview paginator (e925e9d+) loads adjacent sections in background. Events from these loads can interfere with user interactions on the primary section.
|
||||
**Examples:**
|
||||
- `load` event from adjacent section triggers `docLoadHandler` which re-adds ALL annotations, overwriting drag edits
|
||||
- Multiple overlayers with duplicate SVG `<clipPath>` IDs cause `url(#id)` to resolve to wrong element
|
||||
- `MagnifierLoupe` destroying/recreating body clone on every drag tick triggers ResizeObserver → expand → redraw
|
||||
|
||||
**Fix Strategy:** Scope event handlers to the loaded section's index. Use unique IDs for SVG elements across overlayer instances. Minimize iframe DOM mutations during drag operations.
|
||||
|
||||
### 13. Whole-Field-Synced Flag Reaches an Unsupported Platform
|
||||
**Pattern:** A setting is whole-field synced across devices (e.g. `dictionarySettings.providerEnabled`), so a flag enabled on one platform arrives `true` on a platform where that feature isn't supported. The lookup/runtime path correctly gates on platform support, but a *secondary consumer* (usually UI gating) reads the raw synced flag and misbehaves.
|
||||
**Example:**
|
||||
- System Dictionary enabled on macOS synced to web → web's `CustomDictionaries.tsx` locked all other dictionary toggles read-only (`lockedBySystem`) even though System Dictionary is hidden + a no-op there. The annotator's lookup path used the platform-gated `isSystemDictionaryEnabled(settings)` (registry.ts, gates on `isSystemDictionarySupported()`), but the settings UI compared the raw `providerEnabled[systemDictionary] === true`.
|
||||
|
||||
**Fix Strategy:** Every consumer of a synced flag for a platform-specific feature must route through the *same* platform-aware gate the runtime uses — not the raw `providerEnabled[...]`/setting value. Here: `lockedBySystem = isSystemDictionaryEnabled(settings) && ...`. Search for other readers of the raw flag when fixing one.
|
||||
|
||||
## Debugging Workflow
|
||||
|
||||
1. **Identify the category** from the issue description
|
||||
2. **Check `style.ts`** first for any CSS-related visual bugs
|
||||
3. **Check foliate-js** for rendering/layout bugs
|
||||
4. **Check platform-specific code** for mobile/desktop differences
|
||||
5. **Write a failing test** before implementing the fix
|
||||
6. **Test in both paginated and scrolled modes** for layout changes
|
||||
7. **Test on multiple platforms** for any UI change
|
||||
8. **Run `pnpm build-check`** before submitting
|
||||
@@ -1,73 +0,0 @@
|
||||
---
|
||||
name: Cloudflare Workers WebSocket
|
||||
description: How to open and read WebSockets from Cloudflare Workers (the Node `ws` package does not work) and the Blob binary-frame gotcha
|
||||
type: project
|
||||
originSessionId: ec3d5424-adc2-4fca-836f-df323797489c
|
||||
---
|
||||
# Cloudflare Workers WebSocket on readest-app
|
||||
|
||||
## Why the Node `ws` package fails
|
||||
|
||||
The Node `ws` npm package (used transitively by `isomorphic-ws`) opens WebSockets by calling `http.request({ createConnection })`. The Cloudflare Workers runtime does not implement `options.createConnection`, so any attempt to `new WebSocket(url, { headers })` in a Worker throws:
|
||||
|
||||
```
|
||||
The options.createConnection option is not implemented
|
||||
```
|
||||
|
||||
This applies even with `compatibility_flags = ["nodejs_compat"]`.
|
||||
|
||||
## Correct pattern: fetch-based upgrade
|
||||
|
||||
On Workers you open a WebSocket by calling `fetch()` with an `Upgrade: websocket` header against the **https://** (not `wss://`) form of the URL. The response has `status === 101` and a non-standard `webSocket` property that must be `accept()`ed before use:
|
||||
|
||||
```ts
|
||||
const upgradeUrl = url.replace(/^wss:\/\//i, 'https://');
|
||||
const response = (await fetch(upgradeUrl, {
|
||||
headers: { ...baseHeaders, Upgrade: 'websocket' },
|
||||
})) as Response & { webSocket?: WebSocket & { accept(): void } };
|
||||
|
||||
if (response.status !== 101 || !response.webSocket) {
|
||||
throw new Error(`WebSocket upgrade failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const ws = response.webSocket;
|
||||
ws.addEventListener('message', onMessage);
|
||||
ws.accept();
|
||||
ws.send(payload);
|
||||
```
|
||||
|
||||
Detect the Workers runtime with `typeof globalThis.WebSocketPair !== 'undefined'` — `WebSocketPair` is a Workers-only global.
|
||||
|
||||
## Binary frames arrive as Blob (critical)
|
||||
|
||||
Cloudflare Workers deliver WebSocket binary frames as **`Blob`** — not `ArrayBuffer` (browsers) and not `Uint8Array` (Node `ws`). Blob decoding is async via `blob.arrayBuffer()`, so:
|
||||
|
||||
1. You must serialize decodes through a promise chain to keep frames in receive order — otherwise parallel awaits can merge bytes out of order.
|
||||
2. Any terminal text message (e.g. Edge TTS's `Path: turn.end`) arrives **synchronously** and will finalize the stream before the in-flight Blob decodes have flushed. Always `await pendingBinary` in the turn.end handler and the close handler before checking whether data was received.
|
||||
|
||||
Example skeleton:
|
||||
|
||||
```ts
|
||||
let pending: Promise<void> = Promise.resolve();
|
||||
const enqueue = (getBuf: () => Promise<ArrayBufferLike> | ArrayBufferLike) => {
|
||||
pending = pending.then(async () => {
|
||||
const buf = await getBuf();
|
||||
appendBinary(buf);
|
||||
});
|
||||
};
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (data instanceof Blob) enqueue(() => data.arrayBuffer());
|
||||
else if (data instanceof ArrayBuffer) enqueue(() => data);
|
||||
else if (data instanceof Uint8Array) enqueue(() => data.buffer.slice(
|
||||
data.byteOffset, data.byteOffset + data.byteLength,
|
||||
));
|
||||
// ... handle text path: turn.end
|
||||
// -> await pending, then resolve
|
||||
});
|
||||
```
|
||||
|
||||
## Where this is used
|
||||
|
||||
`src/libs/edgeTTS.ts` `#fetchEdgeSpeechWs` has three branches: Tauri (plugin-websocket), Cloudflare Workers (fetch upgrade + Blob handling), and browser/Node fallback (`isomorphic-ws`). The route that exercises the CF branch is `src/app/api/tts/edge/route.ts`, hit when the web client falls back from direct `wss://` (which browsers can't set headers on) to the `/api/tts/edge` HTTPS endpoint.
|
||||
@@ -1,74 +0,0 @@
|
||||
# CSS & Style Fixes Reference
|
||||
|
||||
## The `style.ts` Pipeline (`src/utils/style.ts`)
|
||||
|
||||
This is the most bug-prone file in the codebase (14+ fixes). It handles all EPUB CSS transformations.
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### `getLayoutStyles()`
|
||||
- Always-active styles applied to every EPUB section
|
||||
- Controls: line-height, hyphens, image sizing, table display
|
||||
- Rules here should NOT be conditional on user settings
|
||||
- Common mistake: putting color-related rules here instead of `getColorStyles()`
|
||||
|
||||
#### `getColorStyles()`
|
||||
- Conditionally applied when user enables "Override Book Color"
|
||||
- Controls: foreground/background colors, mix-blend modes, image backgrounds
|
||||
- Gate rules on `overrideColor` flag
|
||||
|
||||
#### `transformStylesheet()`
|
||||
- Regex-based rewriting of EPUB CSS at load time
|
||||
- Runs on every stylesheet loaded from the EPUB
|
||||
- Used to neutralize problematic EPUB CSS declarations
|
||||
|
||||
#### `applyTableStyle()`
|
||||
- Post-render function that scales tables to fit available width
|
||||
- Uses `getComputedStyle()` (not inline `style.width`) to read actual width
|
||||
- Has two scaling paths: column-width-based and parent-container-based
|
||||
|
||||
### Fix History by Issue
|
||||
|
||||
| Issue | Problem | Fix in style.ts |
|
||||
|-------|---------|-----------------|
|
||||
| #3494 | Line spacing not on `<li>` | Added `li` CSS rule for `line-height` and `hyphens` |
|
||||
| #3448 | Calibre colors persist | Moved `.calibre` unset to `getColorStyles()`, added `background-color: unset` |
|
||||
| #3441 | Body padding/margin | Added `padding: unset; margin: unset` to body in `getLayoutStyles()` |
|
||||
| #3316 | Image bg unconditional | Made `background-color` rule conditional on `overrideColor` |
|
||||
| #3377 | Image bg override | Same pattern as #3316, only override when `overrideColor` is true |
|
||||
| #3334 | Generic font-family | `transformStylesheet()` replaces `font-family: serif/sans-serif` with `unset` on body |
|
||||
| #3370 | user-select: none | `transformStylesheet()` replaces all `user-select: none` with `unset` |
|
||||
| #3284 | Table scaling | Added fallback when `totalTableWidth` is 0 but parent has width |
|
||||
| #3351 | Table display broken | Added `display: table !important` to table rule |
|
||||
| #3274 | Image dimensions | Changed selectors to `:where(:not([width]))` and `:where(:not([height]))` |
|
||||
| #3205 | Table width reading | Changed from `style.width` to `getComputedStyle().width`, fixed CSS var unit |
|
||||
| #3112 | Mix-blend on all images | Narrowed selector to `.has-text-siblings` class |
|
||||
| #3086 | Mix-blend on hr | Narrowed selector to `hr.background-img` |
|
||||
| #3012 | Vertical alignment | Fixed available dimensions (subtract insets), replaced `100vw/vh` with CSS vars |
|
||||
|
||||
### Common Patterns
|
||||
|
||||
1. **Adding new element rules:** Copy the pattern from `p` rules (e.g., adding `li` for #3494)
|
||||
2. **EPUB CSS neutralization:** Add regex in `transformStylesheet()` to replace problematic declarations
|
||||
3. **Conditional overrides:** Use `overrideColor`/`overrideLayout` flags to gate rules
|
||||
4. **Selector narrowing:** Use class qualifiers or attribute pseudo-selectors to avoid over-matching
|
||||
5. **Table fixes:** Always use `getComputedStyle()`, not inline style. Check both width paths.
|
||||
|
||||
### CSS Variables from foliate-js
|
||||
|
||||
- `--available-width` - Usable content width (set by paginator.js)
|
||||
- `--available-height` - Usable content height
|
||||
- `--full-width` - Full viewport width (numeric, multiply by 1px)
|
||||
- `--full-height` - Full viewport height (numeric, multiply by 1px)
|
||||
- `--overlayer-highlight-opacity` - Highlight transparency (default 0.3)
|
||||
|
||||
### foliate-js Rendering (`packages/foliate-js/paginator.js`)
|
||||
|
||||
Key functions:
|
||||
- `columnize()` - Paginated layout path
|
||||
- `scrolled()` - Scrolled layout path
|
||||
- `setImageSize()` - Constrains image dimensions to available space
|
||||
- `#replaceBackground()` - Transfers EPUB backgrounds to paginator layer
|
||||
- `snap()` - Swipe gesture detection for page turning
|
||||
|
||||
Common issue: A fix applied to `columnize()` but not `scrolled()` (or vice versa). Always check both paths.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: custom-fonts-reincarnation-4410
|
||||
description: Custom fonts/textures disappear when logged into cloud sync after re-import-after-delete; CRDT remove-wins needs a reincarnation token
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2b61b392-4d32-4516-84bd-f362bba22378
|
||||
---
|
||||
|
||||
# #4410 — disappearing custom fonts when logged into cloud
|
||||
|
||||
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
|
||||
|
||||
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
|
||||
|
||||
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId` → `addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false` → `softDeleteByContentId` → font disappears.
|
||||
|
||||
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
|
||||
|
||||
**Coverage matrix across collection kinds (all share the remove-wins replica):**
|
||||
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
|
||||
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
|
||||
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
|
||||
|
||||
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
|
||||
|
||||
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: D-pad Navigation Design
|
||||
description: Android TV / Bluetooth remote D-pad navigation architecture, key files, and pitfalls encountered during implementation
|
||||
type: project
|
||||
---
|
||||
|
||||
## D-pad Navigation Architecture
|
||||
|
||||
D-pad support enables Bluetooth remote controller navigation on Android TV (and keyboard arrow navigation on desktop).
|
||||
|
||||
### Key Files
|
||||
|
||||
- `src/app/reader/hooks/useSpatialNavigation.ts` — Reader toolbar D-pad navigation. Left/Right navigates between buttons, Up/Down moves between header↔footer. Auto-focuses first button on show. Uses focus-probe technique for visibility detection.
|
||||
- `src/app/library/hooks/useSpatialNavigation.ts` — Library grid D-pad navigation. Arrow keys move between BookshelfItem elements. ArrowDown from outside bookshelf (e.g. header) enters the grid via window-level listener.
|
||||
- `src/helpers/shortcuts.ts` — `onToggleToolbar` (Enter key) toggles reader toolbar visibility.
|
||||
- `src/app/reader/hooks/useBookShortcuts.ts` — `toggleToolbar` handler shows/hides header+footer bars. Skips when a `<button>` is focused (lets native click fire).
|
||||
- `src/__tests__/hooks/useSpatialNavigation.test.tsx` — Unit tests for reader toolbar navigation.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
- **No third-party library**: Tried `@noriginmedia/norigin-spatial-navigation` but it failed due to init timing issues (React child effects run before parent effects) and conflicts with the existing `useShortcuts` system. Custom solution is simpler and more reliable.
|
||||
- **Two `useSpatialNavigation` hooks**: Same name in different directories — library version handles grid navigation, reader version handles toolbar button navigation. Different navigation patterns but same concept.
|
||||
- **Platform-agnostic hooks**: Both `useSpatialNavigation` hooks work on all platforms, not just Android.
|
||||
- **Focus-probe for visibility**: `offsetParent` is unreliable for detecting visible buttons (returns null inside `position: fixed` containers on mobile). Instead, try `btn.focus()` and check if `document.activeElement === btn` — this correctly handles all hiding methods (display:none, visibility:hidden, fixed positioning).
|
||||
|
||||
### Pitfalls
|
||||
|
||||
1. **WebView spatial navigation conflict**: Android WebView has built-in spatial navigation that intercepts D-pad arrow keys and moves DOM focus between `tabIndex>=0` elements. Added `tabIndex={-1}` to non-interactive overlay elements (HeaderBar trigger, ProgressBar, FooterBar trigger, SectionInfo) to prevent focus theft.
|
||||
|
||||
2. **`eventDispatcher.dispatchSync` short-circuits**: When multiple handlers are registered for `native-key-down`, the first handler returning `true` stops propagation. The FooterBar's Back handler fires before the Reader's. Both must independently call `blur()` — can't rely on the Reader's handler running.
|
||||
|
||||
3. **Must blur on toolbar dismiss**: When Back/Escape dismisses the toolbar, the focused button must be blurred. Otherwise `document.activeElement` remains a hidden button, and `toggleToolbar` skips Enter when `activeElement.tagName === 'BUTTON'`. Blur is called in FooterBar's handleKeyDown (for Back and Escape) and in Reader's handleKeyDown.
|
||||
|
||||
4. **Arrow key trapping must use `stopPropagation`**: Without it, arrow keys bubble to `window` where `useShortcuts` handles them as page turns. The toolbar keydown handler on the container div calls `e.stopPropagation()` + `e.preventDefault()` to prevent this.
|
||||
|
||||
5. **Library grid needs window-level listener**: The bookshelf container keydown handler only fires when focus is inside it. A separate `window` keydown listener handles ArrowDown from the header into the grid (when focus is outside the container).
|
||||
|
||||
6. **Auto-focus race on toolbar show**: Both header and footer bars auto-focus their first button when `isVisible` becomes true simultaneously. The last effect to run wins. This is acceptable — user can navigate between them with Up/Down.
|
||||
|
||||
7. **`offsetParent` null in fixed containers**: On mobile, `.footer-bar` uses `position: fixed`. All child buttons have `offsetParent === null`, making `offsetParent`-based visibility checks useless. The focus-probe approach (try focus, check activeElement) is the reliable alternative.
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: duokan-fullscreen-cover-scroll
|
||||
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
|
||||
---
|
||||
|
||||
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
|
||||
|
||||
**Root cause** — `View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
|
||||
|
||||
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
|
||||
|
||||
**Key facts**
|
||||
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
|
||||
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
|
||||
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
|
||||
|
||||
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: empty-start-cfi-sync
|
||||
description: "Invalid synced-progress CFIs like epubcfi(/6/24!/4,,/20/1:58) — the empty-start range bug from the cfi-inert skip-link, and the read-side normalizeLocationCfi sanitizer"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: ffa4a291-55fa-4cd5-8e35-0ac2852ff5c9
|
||||
---
|
||||
|
||||
Synced progress CFIs of the form `epubcfi(/6/24!/4,,/20/1:58)` (a range with an
|
||||
**empty start** component — the `,,`) are invalid: the start collapses to the
|
||||
section beginning `(body, 0)` while the end reaches the section's last block, so
|
||||
a receiving device navigates to the **wrong end** of the section.
|
||||
|
||||
**Root cause** — the cfi-inert a11y skip-link (`a11y.ts` prepends a 1×1
|
||||
`position:absolute` `<div cfi-inert>` as body's first child). There was a
|
||||
~2.5-month transitional window (foliate `c558766` 2026-03-11 → `569cc06`
|
||||
2026-05-30) where `epubcfi.js getChildNodes` already skipped `cfi-inert` but
|
||||
`paginator.js getVisibleRange` did **not** yet reject it. The relocate range's
|
||||
START could anchor on the skip-link; `fromRange`→`nodeToParts` asks for its
|
||||
index, `getChildNodes` filters it out, `findIndex` returns -1, the
|
||||
`.filter(x => x.index !== -1)` drops the step, and the start collapses to the
|
||||
body boundary → empty start. (Symmetric empty-END form from the next-section
|
||||
skip-link on a section's last page.)
|
||||
|
||||
**Generation is fixed** by `569cc06` (live on `dev` via `c23c21d37`) — but that
|
||||
does NOT repair CFIs already stored on the sync server. Those keep being served.
|
||||
|
||||
**Fix (this work):** `isMalformedLocationCfi(cfi)` predicate in `src/utils/cfi.ts`
|
||||
— true for a degenerate range (empty `parts.start` or `parts.end` via
|
||||
`CFI.parse`). Chose **discard over repair** (user call): don't derive a position
|
||||
from a corrupt CFI; drop it and let a known-good fallback win.
|
||||
- Applied ONLY at `useProgressSync.ts` `applyRemoteProgress`: a malformed
|
||||
`syncedConfig.location` is set to `undefined` so it can't drive goTo, can't win
|
||||
the `CFI.compare` gate, and is filtered out of the persisted config (local
|
||||
location kept; stops re-propagation). A valid `xpointer` still recovers the
|
||||
real position via `getCFIFromXPointer`.
|
||||
- Applied at `useKOSync.ts` `generateKOProgress` (push side): if local
|
||||
`progress.location` is malformed, skip the CFI→XPointer conversion and reuse
|
||||
the last known-good `config.xpointer`. Critical because once a bad CFI is
|
||||
pushed as an XPointer the "malformed" signal is lost — other devices pull a
|
||||
plain XPointer pointing at the wrong section end and can't discard it. The
|
||||
kosync RECEIVE path needs no guard: `getCFIFromXPointer` builds point CFIs from
|
||||
point XPointers, which can't take the empty-start form.
|
||||
- Deliberately NOT applied to `FoliateViewer.tsx` open path — that uses the
|
||||
user's OWN local `config.location`; discarding it would dump them at book start
|
||||
(`goToFraction(0)`). Left untouched per user preference; a legacy local bad
|
||||
value self-heals on the next page-turn save.
|
||||
|
||||
Tests: predicate in `__tests__/utils/cfi.test.ts`; repro + flag in
|
||||
`__tests__/utils/epubcfi-inert.test.ts`; discard behavior (no goTo, not
|
||||
persisted) in `__tests__/hooks/useProgressSync.test.tsx`.
|
||||
Related: [[kosync-cfi-spine-resolution]].
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
name: Design rules live in DESIGN.md
|
||||
description: Readest has a design system doc — codify recurring UI/UX rules there, don't just apply them ad-hoc. Memory points at the canonical location and the patterns it covers.
|
||||
type: feedback
|
||||
originSessionId: 85757e57-a029-40f8-b098-88039c43514b
|
||||
---
|
||||
The project's design system is documented at `apps/readest-app/DESIGN.md`.
|
||||
**When the user articulates a UI/UX rule** ("X should always Y", "we follow Z
|
||||
convention"), add it to DESIGN.md so it persists for the team and for future
|
||||
sessions — don't just apply it inline and move on.
|
||||
|
||||
**Why:** Readest's UI is Adwaita-aligned, e-ink-first, cross-platform-aware.
|
||||
A doc'd system avoids drift across panels and gives reviewers a reference
|
||||
point. The user explicitly asked to "remember the UI/UX rules somewhere"
|
||||
when refining the OPDS Integration sub-page.
|
||||
|
||||
**How to apply:** When the user surfaces a new rule:
|
||||
1. Add it to the appropriate `DESIGN.md` section (numbered principles in §2,
|
||||
anatomy details in §5, anti-patterns in §10).
|
||||
2. Cross-reference from related sections so it's discoverable from multiple
|
||||
entry points.
|
||||
3. Save the actual code change reference if it captures a canonical example.
|
||||
|
||||
**Rules already codified there (don't re-invent — reference instead):**
|
||||
- §2.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,25 +0,0 @@
|
||||
---
|
||||
name: iframe-cross-realm-instanceof
|
||||
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
|
||||
---
|
||||
|
||||
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
|
||||
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
|
||||
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
|
||||
|
||||
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
|
||||
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
|
||||
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
|
||||
yet are dead in the running app.
|
||||
|
||||
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
|
||||
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
|
||||
(numeric constant, realm-safe) or check `classList`/`closest` presence.
|
||||
|
||||
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
|
||||
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
|
||||
never tripped. Related: [[foliate-touch-listener-capture-phase]].
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: issue-4112-scroll-anchoring
|
||||
description: "Root cause for readest#4112 scrolled-mode backward-nav bugs — scroll-anchoring suppressed at scrollTop 0 when prepending a section"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: e0b11058-53ee-4554-a518-134f788823ee
|
||||
---
|
||||
|
||||
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
|
||||
|
||||
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
|
||||
|
||||
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
|
||||
|
||||
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/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,18 +0,0 @@
|
||||
---
|
||||
name: koplugin-cover-upload
|
||||
description: "koplugin cover system — where local/cloud covers come from, and the"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
|
||||
---
|
||||
|
||||
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
|
||||
|
||||
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
|
||||
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
|
||||
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
|
||||
|
||||
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
|
||||
|
||||
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
name: readest.koplugin i18n system
|
||||
description: Custom gettext loader, .po catalog layout, and extract/apply scripts for the KOReader plugin at apps/readest.koplugin/
|
||||
type: reference
|
||||
originSessionId: 08cfd0cd-b710-4674-9c90-d2ae4827d071
|
||||
---
|
||||
The KOReader plugin (`apps/readest.koplugin/`) has its own gettext-based i18n system, parallel to but separate from the readest-app i18next setup.
|
||||
|
||||
## Loader
|
||||
|
||||
- File: `apps/readest.koplugin/i18n.lua` — isolated module, returns a callable table via `setmetatable({}, {__call = ...})`, so `_("msg")` syntax works as a drop-in replacement for `require("gettext")`. Provides `ngettext`/`pgettext`/`npgettext` too. Falls back to KOReader's native `gettext` for missing strings.
|
||||
- All Lua sources do `local _ = require("i18n")` (not `require("gettext")`).
|
||||
- **Never rename `i18n.lua` to `gettext.lua`** — it would shadow KOReader's module via `require("gettext")` and break the fallback chain (recursive require / never loaded).
|
||||
|
||||
## Catalog layout
|
||||
|
||||
- `apps/readest.koplugin/locales/<lang>/translation.po` — mirrors `apps/readest-app/public/locales/<lang>/translation.json` exactly, using **i18next-style codes** (`zh-CN`, not `zh_CN`).
|
||||
- The loader converts KOReader's locale (e.g. `zh_CN.utf8` → `zh-CN`) before lookup.
|
||||
- Fallback chain: full code → base lang (`pt_BR` → `pt`) → `zh-CN` for any unspecified zh variant.
|
||||
- Language list is the single source of truth at `apps/readest-app/i18next-scanner.config.cjs` (`options.lngs`) — currently 31 languages.
|
||||
|
||||
## Scripts (in `apps/readest.koplugin/scripts/`)
|
||||
|
||||
- **`extract-i18n.js`** — primary tool. Run with `node scripts/extract-i18n.js`. Scans `*.lua` for `_("...")`, `_('...')`, and `_([[...]])` (with proper Lua-escape handling), reads each `.po`, **preserves existing translations**, adds new msgids with empty `msgstr`, drops obsolete msgids. Idempotent.
|
||||
- **`apply-translations.js`** — bulk applier. Reads `/tmp/koplugin-translations/<lang>.json` files (key = msgid, value = translation) and fills empty `msgstr ""` lines only — **never overwrites** existing translations.
|
||||
|
||||
## Workflow for adding/changing strings
|
||||
|
||||
1. Edit Lua source(s). Use `_("Foo")` or `T(_("Foo %1"), arg)` (`T` from `require("ffi/util").template`) — **never** `_("Foo ") .. arg`, because RTL/verb-final languages can't reorder the placeholder.
|
||||
2. `node scripts/extract-i18n.js` — adds new empty msgids, drops obsolete.
|
||||
3. To translate: drop `<lang>.json` files into `/tmp/koplugin-translations/`, then `node scripts/apply-translations.js`.
|
||||
4. Verify with `luac -p apps/readest.koplugin/*.lua` and re-run `extract-i18n.js` (should report no changes — idempotency check).
|
||||
|
||||
## Translation conventions
|
||||
|
||||
- Brand names "Readest" and "KOReader" stay untranslated.
|
||||
- Technical terms ("PDF", "API", "URL", "Supabase", "Hash") generally kept as-is, sometimes transliterated in non-Latin scripts.
|
||||
- Dialog title = title case (`Sync Info`); menu item label = sentence case (`Sync info`).
|
||||
- Lower-confidence translations (bo, si, ta, bn, sl, fa) deserve native-speaker review.
|
||||
|
||||
## Storage conventions for plugin state
|
||||
|
||||
- Global plugin state: `G_reader_settings:saveSetting("readest_sync", settings)` — login tokens, auto-sync flag, etc.
|
||||
- Per-book state: `ui.doc_settings:readSetting("readest_sync")` table — keys like `meta_hash_v1`, `last_synced_at_config`, `last_synced_at_notes` (seconds since epoch).
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: kosync-cfi-spine-resolution
|
||||
description: "KOSync CFI↔XPointer conversion must resolve via the CFI's own spine index, not the paginator's primaryIndex (which lags during scroll)"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 41b15d60-969d-4173-9db7-d1515721e527
|
||||
---
|
||||
|
||||
KOSync progress push/pull converts between foliate CFI and KOReader XPointer via `src/utils/xcfi.ts`. `XCFI` is constructed for ONE spine section (`spineItemIndex`) and `cfiToXPointer`/`xPointerToCFI` throw `CFI spine index N does not match converter spine index M` if asked to convert a CFI from a different section.
|
||||
|
||||
**Pitfall:** `view.renderer.primaryIndex` lags behind the viewport during scrolling (see `paginator.js` `#primaryIndex` comment). So `progress.location` can be a CFI in a different spine section than the currently-rendered primary view. Building the converter from the primary view's `doc`/`index` then converting `progress.location` throws on mismatch.
|
||||
|
||||
**Fix (PR branch fix/kosync-spine-index):** always go through the exported helpers `getXPointerFromCFI(cfi, doc?, index?, bookDoc)` / `getCFIFromXPointer(...)` in `xcfi.ts`. They call `XCFI.extractSpineIndex(cfi)` and, when the passed `index` ≠ the CFI's spine, load the correct section's document via `bookDoc.sections[xSpineIndex].createDocument()`. `generateKOProgress` in `src/app/reader/hooks/useKOSync.ts` had hand-rolled `new XCFI(primaryDoc, primaryIndex)` instead of using the helper — that was the bug.
|
||||
|
||||
**Why:** the helper already handles cross-section resolution correctly; never reconstruct the converter inline against `primaryIndex`.
|
||||
|
||||
**How to apply:** for any CFI→XPointer or XPointer→CFI in KOSync code, use the `getXPointerFromCFI`/`getCFIFromXPointer` helpers and pass `bookDoc` so off-screen sections can be loaded. Related: [[issue-4112-scroll-anchoring]] (same primaryIndex/scroll-lag family). The companion foliate `fromRange` crash (`Cannot destructure property 'nodeType'`) during relocate is separate scroll-lag noise, not the sync blocker.
|
||||
|
||||
**Conflict-detection comparison (`promptedSync` in `useKOSync.ts`):** KOReader's reported `remote.percentage` comes from CREngine pagination and is NOT comparable to Readest's progress. For reflowable books, convert the remote XPointer → local CFI → `view.getCFIProgress(cfi).fraction` (helper `getRemoteLocalFraction` in `kosyncProgress.ts`) and compare THAT to the local percentage; fall back to `remote.percentage` only when it can't be resolved locally (non-XPointer/Kavita progress or missing section). The resolved fraction also drives the "Approximately X%" remote preview so the dialog shows what was actually compared. Fixed-layout still compares page-derived `remotePercentage`. Threshold: `0.0001` cross-device, but loosened to `0.01` when `remote.device_id === settings.kosync.deviceId` (same device = our own earlier push; don't prompt on sub-page drift). Percentages render via `formatProgressPercentage` (2 decimals, `kosyncPreview.ts`).
|
||||
@@ -1,70 +0,0 @@
|
||||
# Layout & UI Fixes Reference
|
||||
|
||||
## Safe Area Insets
|
||||
|
||||
### Architecture
|
||||
- Native plugins push inset values: iOS (`NativeBridgePlugin.swift`), Android (`NativeBridgePlugin.kt`)
|
||||
- `useSafeAreaInsets` hook (`src/hooks/useSafeAreaInsets.ts`) reads and caches values
|
||||
- Components use `gridInsets` for positioning relative to safe areas
|
||||
- CSS: `env(safe-area-inset-top/bottom/left/right)` for CSS-level insets
|
||||
|
||||
### Common Issues
|
||||
- **Stale insets after system UI change** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
|
||||
- **iPad sidebar insets wrong** (#3395): Different inset handling needed for sidebar vs main view
|
||||
- **Android nav bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for Android bottom padding
|
||||
- **Zoom controls behind status bar** (#3426): Pass `gridInsets` through component chain, use `Math.max(gridInsets.top, statusBarHeight)`
|
||||
|
||||
### Rules (see also `docs/safe-area-insets.md`)
|
||||
- Always pass `gridInsets` to overlay/floating components near screen edges
|
||||
- On Android, account for system navigation bar with `env(safe-area-inset-bottom)`
|
||||
- After toggling system UI visibility, force-refresh insets via `onUpdateInsets()`
|
||||
|
||||
## Z-Index Hierarchy
|
||||
- Navigation buttons: `z-10` (when visible)
|
||||
- Annotation nav bar: `z-10` (reduced from z-30 in #3386)
|
||||
- TTS control button: ensure above page nav buttons
|
||||
- Floating overlays: check against `gridInsets` positioning
|
||||
|
||||
## macOS Traffic Lights
|
||||
- Managed by `trafficLightStore.ts` and `HeaderBar.tsx`
|
||||
- **Fullscreen check required** (#3129): `await currentWindow.isFullscreen()` before hiding
|
||||
- **Timeout for visibility toggle** (#3488): Use 100ms delay to prevent flickering
|
||||
- **Sidebar interaction** (#3488): Check `getIsSideBarVisible()` before hiding traffic lights
|
||||
- Never hide traffic lights when sidebar is open
|
||||
|
||||
## Touch/Input Issues
|
||||
- **Slider hit area on iOS** (#3382): Use min-h-12, strip browser appearance with CSS
|
||||
- **Context menu on macOS** (#3324): 100ms delay before `onContextMenu()` to let pointer events complete
|
||||
- **Swipe sensitivity** (#3310): Use average velocity (distance/time) instead of instantaneous velocity for non-animated paging
|
||||
- **Touchpad natural scrolling** (#3127): Respect system natural scrolling setting in `usePagination.ts`
|
||||
|
||||
## Dialog/Menu Layout
|
||||
- **Dialog header** (#3352): Use `px-2 sm:pe-3 sm:ps-2` padding to align with border radius
|
||||
- **Settings alignment** (#3151): Use `Menu` component instead of raw `div` for consistent styling
|
||||
- **Dropdown for screen readers** (#3035): Use `DropdownContext` with overlay dismiss, not blur-based closing
|
||||
|
||||
## Component-Specific Fixes
|
||||
|
||||
### HeaderBar.tsx
|
||||
- Traffic light visibility management
|
||||
- Sidebar toggle persistent position (#3193)
|
||||
- Library button placement
|
||||
|
||||
### PageNavigationButtons.tsx
|
||||
- z-10 when visible (#3201)
|
||||
- Always shown for screen readers (#3036)
|
||||
- Toggle via `showPageNavButtons` setting
|
||||
|
||||
### ProgressInfo.tsx
|
||||
- Use physical `page`/`pages` from renderer, not estimated values (#3213, #3200)
|
||||
- CSS classes: `time-left-label`, `pages-left-label`, `progress-info-label` (#3343)
|
||||
|
||||
### ReadingRuler.tsx
|
||||
- Remove `containerStyle` from overlay so dimmed area covers full screen (#3304)
|
||||
|
||||
### NavigationBar.tsx
|
||||
- Handle `gridInsets` internally, not via pre-computed `navPadding` (#3466)
|
||||
|
||||
### ContentNavBar.tsx (annotation search results)
|
||||
- Floating buttons with drop shadow, not full-width bar (#3386)
|
||||
- z-10 z-index
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: manage-cache-ios-layout
|
||||
description: "iOS app container layout and what the Manage Cache feature can/can't clear"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 3512356b-a453-42d3-99f6-1ca43d06dd1e
|
||||
---
|
||||
|
||||
Manage Cache feature: `src/utils/cache.ts` (helpers `getCacheEntries`/`getCacheStats`/`clearCacheEntries` over `CacheSource[]`), `src/app/library/components/CacheManagerWindow.tsx` (singleton dialog, `setCacheManagerDialogVisible`, `getCacheSources()` composes the source list), menu item in `SettingsMenu.tsx` Advanced Settings, mounted in `library/page.tsx`.
|
||||
|
||||
Scope: **native mobile apps only** (gated on `appService?.isMobileApp`; hidden on desktop + web). Sources cleared: iOS → Cache + Temp + `Documents/Inbox`; Android → Cache + Temp.
|
||||
|
||||
On-device analysis (dev build `com.bilingify.readest`, pulled via `xcrun devicectl device copy from --domain-type appDataContainer`):
|
||||
|
||||
- The `'Cache'` base = Tauri `appCacheDir()` = **`Library/Caches/com.bilingify.readest`** only — NOT all of `Library/Caches`. On the test device this held ~272 MB: a 249 MB duplicate dictionary (`concise-enhanced.mdx`, canonical copy lives in `App Support/Readest/Dictionaries/<id>/`), ~23 MB duplicate import-staged epubs (canonical in `App Support/Readest/Books/<hash>/`), the `search/` results cache, plus tiny system scratch. All safe to clear — every large item is an orphaned import/download staging duplicate.
|
||||
- iOS open-in leftovers: **`Documents/Inbox`** (resolved via `documentDir()` + join `Inbox`, scanned/cleared with base `'None'` + absolute paths). Already-imported books linger here. The feature now clears it on iOS. (`tmp/<bundle>-Inbox` also exists but was empty.)
|
||||
- NOT reachable by the `'Cache'` base: `Library/Caches/WebKit` (~173 MB, WKWebView disk cache — needs native `WKWebsiteDataStore.removeData`) and `tmp/` blob scratch (~59 MB, maps to `'Temp'` base, not currently cleared). These are the bigger "free up space" wins if the feature is ever expanded.
|
||||
- Never clear `Library/Application Support/com.bilingify.readest/Readest` — the real Books/Dictionaries/Fonts/DB (~1.7 GB).
|
||||
|
||||
Root-cause follow-up worth doing: the import pipeline leaves staging copies in the cache root and `Documents/Inbox` instead of deleting them after import.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
name: paginated-texture-occlusion-4399
|
||||
description: Background texture absent in paginated mode (shown in scrolled) — opaque
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 1bd8a73e-f279-4ed8-9562-98e96d9723d5
|
||||
---
|
||||
|
||||
RESOLVED — merged. foliate-js `142bf11` (on main) + readest pointer bump/test
|
||||
(`fix(reader): show background texture in paginated mode (#4399)`).
|
||||
|
||||
#4399: a background texture (Settings → Color → texture, e.g. Leaves) shows in
|
||||
**scrolled** mode but is **absent in paginated** mode. The texture is NOT in the
|
||||
iframe — it's mounted on the HOST as `.foliate-viewer::before` (`src/styles/textures.ts`,
|
||||
`mountBackgroundTexture`): `position:absolute; inset:0; z-index:0; mix-blend-mode:multiply;
|
||||
opacity:.6` over the reader container. For it to show, the whole foliate view tree
|
||||
(iframe page bg + the paginator `#background` layer) must be transparent so the host
|
||||
`::before` composites over the white page fill that a **parent** element provides
|
||||
(`oklch(1 0 0)` on the reader grid cell, NOT on `.foliate-viewer`, which is transparent).
|
||||
|
||||
**Root cause.** foliate-js `paginator.js` `#replaceBackground`. Scrolled mode already
|
||||
left things transparent under a texture (`#background.style.background = hasTexture ? '' : fallbackBg`
|
||||
+ blanking transparent view elements). The **paginated** branch hard-set
|
||||
`this.#background.style.background = fallbackBg` (opaque theme color, e.g. white) on the
|
||||
segment **container** — that opaque container, a descendant of `.foliate-viewer`, paints
|
||||
over the host `::before` texture. The per-view *segments* were already transparent for
|
||||
transparent pages (`rgba(0,0,0,0)`); only the container was the occluder. Verified live
|
||||
via Chrome MCP: `#background` inline bg was `rgb(255,255,255)`; setting it `''` revealed
|
||||
the leaves texture instantly.
|
||||
|
||||
**Regression** = commit `167757a` "Fix background flash when swiping between
|
||||
differently-colored pages" (2026-05-31, see [[paginator-swipe-bg-flash]]). The OLD
|
||||
paginated path was a CSS `grid` of per-column divs and never set the `#background`
|
||||
container background (kept the `''` reset), so a transparent page let the texture through.
|
||||
167757a swapped to `computeBackgroundSegments` and added the opaque container line.
|
||||
|
||||
**Fix.** Extract a shared exported pure helper `textureAwareBackground(resolved, hasTexture)`
|
||||
→ returns `''` when `hasTexture && isTransparent(resolved)`, else `resolved`. Use it for
|
||||
BOTH scrolled view elements and paginated segment bgs, and set the paginated container
|
||||
`this.#background.style.background = hasTexture ? '' : fallbackBg` (mirroring scrolled).
|
||||
No-texture path unchanged → swipe-flash fix for colored pages fully preserved; a
|
||||
book-forced opaque page still paints its segment (texture correctly does not show there).
|
||||
|
||||
Test: `src/__tests__/document/paginator-background-segments.test.ts` (new `describe` for
|
||||
the pure helper, alongside `computeBackgroundSegments`). foliate-js is a submodule —
|
||||
commit there + bump the pointer. See [[paginator-swipe-bg-flash]].
|
||||
@@ -1,71 +0,0 @@
|
||||
---
|
||||
name: paginator-gutter-bleed-asymmetry-4394
|
||||
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 108a70bd-5af9-4a31-9b93-9d6df7637579
|
||||
---
|
||||
|
||||
#4394 "page viewport offset / content clipping." On a 2-up cover spread the
|
||||
left page (cover `<img>`, transparent page bg) had a white outer gutter while
|
||||
the right page (`<body class="c3">` body-colour yellow) bled its colour past its
|
||||
column into the right outer gutter → spread looked shifted right. On a wide
|
||||
screen (1920px, 720 max-inline) the gutters are ~250px so it reads as a "massive
|
||||
white blank gap." 0.10.6 filled colour edge-to-edge; 0.11.2 regressed.
|
||||
|
||||
**Geometry.** Grid `#top` = `minmax(--_outer-min-left,1fr) margin-left
|
||||
minmax(0,maxw) margin-right minmax(--_outer-min-right,1fr)` (foliate
|
||||
`paginator.js`). `#container` = grid-column 2/5 (inset by the outer gutter
|
||||
tracks); `#background` = grid-column 1/-1 (spans the gutters). `--_outer-min =
|
||||
(col_count-1)*(margin/4 + gap/4)` — non-zero ONLY in multi-column (0 in
|
||||
1-column), added in commit `0a0ceda` so the outer margin matches half the
|
||||
inter-column gap (symmetric spacing). `inset = containerLeft - bgLeft =
|
||||
--_outer-min-left`.
|
||||
|
||||
**Cause.** `computeBackgroundSegments` (the swipe-flash fix, commit `167757a`)
|
||||
positioned each page's colour segment at `inset + offset - scrollPos` and then
|
||||
STRETCHED any segment touching a container edge OUT to the bg edge (`start=0` /
|
||||
`end=bgSize`) — i.e. bled into the outer gutter. A body-coloured page bled into
|
||||
its gutter; a transparent/image page (cover) did not (its `#background` is
|
||||
transparent; its `<img>` is clamped inside the inset `#container`). Mixed spread
|
||||
→ one gutter coloured, one not → asymmetric.
|
||||
|
||||
**Fix (what the maintainer wanted).** Do NOT touch `--_outer-min` (it keeps the
|
||||
left/right margins symmetric with the centre gap). Instead CLAMP each segment to
|
||||
the content area so the background stays inside its column and never overflows
|
||||
into the outer gutter:
|
||||
```js
|
||||
const start = Math.max(segStart, containerStart) // was: if(...) start = 0
|
||||
const end = Math.min(segEnd, containerEnd) // was: if(...) end = bgSize
|
||||
if (end <= start) continue
|
||||
```
|
||||
`containerStart=inset`, `containerEnd=inset+containerSize`. In single-column the
|
||||
gutters are 0 (`--_outer-min`=0 → inset 0) so this still fills the viewport edge
|
||||
to edge (matches 0.10.6); in multi-column each page stays in its column with
|
||||
symmetric gutter margins. Image pages (cover/彩页) were already correct (`<img>`
|
||||
sits in its column); only the body-colour `#background` overflow needed fixing.
|
||||
|
||||
**Dead ends (took 2 wrong tries before the maintainer steered me right).**
|
||||
1. Thought it was the gutter-bleed and "gated" the stretch on both-edges-coloured
|
||||
— maintainer: "nothing to do with the gutter" (meaning don't change the bleed
|
||||
GATING, the real issue is the offset).
|
||||
2. Thought the title page shouldn't be yellow at all — it genuinely is
|
||||
`.c3{background:#ffe43f}` on `<body>`, captured as `docBackground` and painted
|
||||
full-bleed (body is zeroed via `doc.body.style.background='none'`, the old
|
||||
f087826 "dismiss iframe background, paint via root" design). Yellow is correct,
|
||||
it was just overflowing.
|
||||
3. Proposed dropping `--_outer-min` (revert `0a0ceda`) — maintainer rejected:
|
||||
that desymmetrises the centre-vs-side gaps. Keep the grid; fix the background.
|
||||
|
||||
**Verifying live.** foliate-js is a submodule; `next dev` / turbopack did NOT
|
||||
hot-reload an edit to `packages/foliate-js/paginator.js` even across full
|
||||
navigations — had to RESTART the dev server (`pnpm dev-web`, port 3000) to pick
|
||||
it up. Cheap interim check: clamp the live `#background` segment divs in the page
|
||||
and screenshot. Segment colours are render-timing dependent (a probe right after
|
||||
nav can show all-transparent before the body-colour is captured).
|
||||
|
||||
Tests: `apps/readest-app/src/__tests__/document/paginator-background-segments.test.ts`
|
||||
(tests "centered page" + "two-up spread" flipped from full-bleed to confined; the
|
||||
inset=0 swipe-flash tests are unchanged). Related: [[paginator-swipe-bg-flash]]
|
||||
[[paginated-texture-occlusion-4399]].
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: paginator-swipe-bg-flash
|
||||
description: Swipe page-turn background flash —
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 374255ae-fbfd-4933-bc47-555e541fa115
|
||||
---
|
||||
|
||||
Swipe page-turn flash (white↔black pages) in the multiview paginator. Only on
|
||||
**swipe + animation** (not arrow keys, not animation-off). Repro: mobile
|
||||
emulator single column, swipe between a transparent page (colour comes from the
|
||||
host behind, e.g. a white cover) and a CSS-coloured page (e.g. `body{background:#000!important}`).
|
||||
`page2→page1` (black→white, backward) is 100%. Tell-tale: **slow swipe = big
|
||||
flash, quick swipe = small flash on the trailing side** (flash width == drag distance).
|
||||
|
||||
**Root cause.** `#background` (foliate-js `paginator.js`) was a static
|
||||
screen-space layer (a `repeat(cc,1fr)` grid coloured by each column's midpoint).
|
||||
Doc bodies are set transparent (`doc.body.style.background='none'`), so a page's
|
||||
full-bleed colour is painted by `#background`, NOT the iframe — except pages that
|
||||
force their colour with `!important`, which keep it in-iframe. During a swipe the
|
||||
content moves but `#background` did not: (1) the **drag** scrolls via `scrollBy`
|
||||
with no `#replaceBackground` call (the debounced scroll handler doesn't fire
|
||||
mid-drag), so the incoming page rendered over the *outgoing* page's stale colour;
|
||||
(2) the **snap** pre-set the background to the *destination* (`#replaceBackground(offset)`)
|
||||
then slid only the content for 300ms, so the outgoing page lost its colour
|
||||
instantly and flashed across the area it still covered. Arrow keys don't flash
|
||||
because they start from rest (content already aligned with the pre-set destination).
|
||||
|
||||
**Fix** (both phases needed — drag-only fix leaves the snap flash):
|
||||
- `computeBackgroundSegments(views, scrollPos, bgSize, inset, containerSize)` —
|
||||
exported pure helper. One full-bleed segment per rendered view at
|
||||
`inset + viewOffset - scrollPos`; transparent (`bg===''`) views get no segment
|
||||
(host/theme shows through); segments meeting a container edge stretch into the
|
||||
full-bleed gutter. `#background` is now `position:relative; overflow:hidden`
|
||||
with absolutely-positioned segment divs (not a grid).
|
||||
- Drag: rebuild every scroll in the container `scroll` listener, gated on
|
||||
`!this.scrolled && !this.#isAnimating`.
|
||||
- Snap (`#scrollTo`): build at the **current** position, then a per-rAF
|
||||
`syncBackground` loop reads the animated view's transform
|
||||
(`new DOMMatrix(getComputedStyle(child).transform).m41`, m42 for vertical) and
|
||||
calls `#replaceBackground(startPosition - tx)` so segments track the content
|
||||
every frame. Per-frame rebuild (not a CSS translate) because the incoming
|
||||
page's segment must *grow* as it slides in.
|
||||
|
||||
Key insight: `expand()` makes a view element exactly `contentPages*columnSize`
|
||||
wide, so per-view segments == the old per-column grid at rest, but they can slide.
|
||||
|
||||
Test: `src/__tests__/document/paginator-background-segments.test.ts` (pure helper).
|
||||
Visual repro = synthetic `Touch` events via Chrome MCP + an rAF timeline sampling
|
||||
`#background` segment `{left,width,bg}` + the view transform through drag AND snap.
|
||||
foliate-js is a submodule — commit there + bump the pointer. See [[issue-4112-scroll-anchoring]].
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
name: pdfjs-vendor-wasm-decoders
|
||||
description: Scanned PDFs blank in CI builds but fine locally — pdfjs wasm decoders (jbig2.wasm) not copied to public/vendor/pdfjs
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9066c0b0-71a6-4db0-92c1-c3ccacf1ff0d
|
||||
---
|
||||
|
||||
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
|
||||
|
||||
**Root cause:** `pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
|
||||
|
||||
**Why local masked it:** `pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
|
||||
|
||||
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
|
||||
|
||||
**Gotchas for future:**
|
||||
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
|
||||
- `cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
|
||||
- Related: [[platform-compat-fixes]], [[bug-patterns]].
|
||||
@@ -1,91 +0,0 @@
|
||||
# Platform Compatibility Fixes Reference
|
||||
|
||||
## Android
|
||||
|
||||
### WebView API Issues
|
||||
- **`navigator.getGamepads()`** returns null on older WebView (#3245): Always null-check before `.some()`
|
||||
- **`CompressionStream`** unavailable on some Android WebView versions (#3255): Add fallback zip compression path
|
||||
- **Annotation tools unresponsive** (#3225): Don't call `makeSelection()` immediately on pointer-up; let popup flow complete naturally
|
||||
- **Safe inset updates** (#3469): Call `onUpdateInsets()` after `setSystemUIVisibility()`
|
||||
- **Navigation bar overlap** (#3466): Use `calc(env(safe-area-inset-bottom) + 16px)` for bottom padding
|
||||
|
||||
### General Android Rules
|
||||
- Test with older WebView versions (97+)
|
||||
- Always check API availability before calling Web APIs
|
||||
- Touch event handling differs from iOS - avoid premature re-selection
|
||||
|
||||
## iOS
|
||||
|
||||
### Common Issues
|
||||
- **Slider touch dead zones** (#3382): Strip native appearance, use larger hit areas (min-h-12)
|
||||
- **Safe area insets stale** (#3395): Native Swift plugin must push updated insets
|
||||
- **Section content caching** (#3242, #3206): Don't cache section content in foliate-js when updating subitems; cached content retains stale styles after mode switch
|
||||
- **CompressionStream** (#3255): Also broken on iOS 15.x; zip.js has its own native API disable
|
||||
- **zip.js native API** (#3170): Disable native `CompressionStream`/`DecompressionStream` on iOS 15.x
|
||||
|
||||
### iPad reports a desktop UA → never branch native dispatch on `getOSPlatform()`
|
||||
- `getOSPlatform()` (utils/misc) is **user-agent based**, and iPadOS sends a desktop "Macintosh" UA → it returns `'macos'` on iPad. Any native-OS dispatch keyed on it misroutes iPad to the macOS path.
|
||||
- Symptom seen: system dictionary on iPad threw `"Command show_lookup_popover not found"` — the macOS-only Rust command (`src-tauri/src/macos/system_dictionary.rs`); iOS only registers the plugin command `plugin:native-bridge|show_lookup_popover`.
|
||||
- **Rule:** for OS-specific native dispatch/capability, use `appService.isIOSApp / isMacOSApp / isAndroidApp` (derived from the Tauri OS plugin `type()` → `OS_TYPE` in `nativeAppService.ts`), NOT `getOSPlatform()`. The misc.ts comment says this explicitly.
|
||||
- Sync, non-React modules: `getInitializedAppService()` (environment.ts) returns the cached singleton synchronously (null pre-init). Used by `systemDictionary.ts` for `isSystemDictionarySupported()` (sync) + `invokeSystemDictionary()` (async).
|
||||
|
||||
### iOS-Specific Code
|
||||
- `src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift`
|
||||
- Slider CSS: `-webkit-appearance: none; appearance: none` in globals.css
|
||||
- `useSafeAreaInsets.ts` hook
|
||||
|
||||
## macOS
|
||||
|
||||
### Traffic Lights (Window Controls)
|
||||
- Check `isFullscreen()` before hiding (#3129)
|
||||
- Use timeouts (100ms) for visibility transitions (#3488)
|
||||
- Don't hide when sidebar is open (#3488)
|
||||
|
||||
### Input Issues
|
||||
- **Context menu steals event loop** (#3324): 100ms setTimeout before calling menu.popup()
|
||||
- **Touchpad natural scrolling** (#3127): Respect system setting in `usePagination.ts`
|
||||
- **Two-finger swipe** (#3127): Support trackpad two-finger swipe for pagination
|
||||
|
||||
### macOS-Specific Code
|
||||
- `src-tauri/src/macos/` - Platform-specific Rust code
|
||||
- `src/store/trafficLightStore.ts`
|
||||
- `src/hooks/useTrafficLight.ts`
|
||||
|
||||
## Linux
|
||||
|
||||
### WebKitGTK Issues
|
||||
- **View Transitions API unsupported** (#3417): Feature-detect `document.startViewTransition` before calling
|
||||
- Use `useAppRouter.ts` to avoid transitions on Linux
|
||||
|
||||
## E-ink Devices
|
||||
|
||||
### Legibility Issues
|
||||
- **Low contrast colors** (#3258): Use `not-eink:` Tailwind variant prefix for colors/opacity
|
||||
- **Links invisible** (#3258): Don't apply `text-primary` (blue) on e-ink; use default text color
|
||||
- **Opacity too low** (#3258): Don't apply `opacity-60`/`opacity-75` on e-ink devices
|
||||
- **Highlight visibility** (#3299): Use foreground color for highlights in dark mode e-ink
|
||||
|
||||
### E-ink CSS Pattern
|
||||
```
|
||||
// Instead of:
|
||||
className="text-primary opacity-60"
|
||||
// Use:
|
||||
className="not-eink:text-primary not-eink:opacity-60"
|
||||
```
|
||||
|
||||
## Docker/Self-Hosting
|
||||
- **Missing submodules** (#3233): Run `git submodule update --init --recursive` before build
|
||||
- Simplecc WASM module must be initialized
|
||||
|
||||
## OPDS
|
||||
- **Non-ASCII credentials** (#3436): Use `TextEncoder` + manual Base64 instead of `btoa()`
|
||||
- **Author parsing** (#3120): Handle varied metadata structures in OPDS 2.0 feeds
|
||||
- **Responsive layout** (#3418): Ensure catalog and download button layout works on small screens
|
||||
|
||||
## Cross-Platform Testing Checklist
|
||||
1. Android (old WebView + current)
|
||||
2. iOS (15.x + current)
|
||||
3. macOS (traffic lights, trackpad)
|
||||
4. Linux (WebKitGTK)
|
||||
5. E-ink devices (contrast, colors)
|
||||
6. Web (CloudFlare Workers deployment)
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: progressbar-focus-ring-4397
|
||||
description: ProgressBar footer painted a stray focus-ring line on Android long-press; fix = remove tabIndex from the decorative div
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 7d650c44-2d60-4a88-a9f5-6b5956bca59b
|
||||
---
|
||||
|
||||
#4397 "Strange line on the bottom of the page when long-pressing the footer" (Android 13, 0.11.2).
|
||||
|
||||
**Symptom:** a thin horizontal line, content-column wide, across the last text line / bottom margin. Appears after long-pressing the footer + turning a page; persists across page turns; cleared by double-tapping the footer + turning a page. Maintainer couldn't repro on eink (device/WebView-specific long-press focus behavior).
|
||||
|
||||
**Root cause:** `ProgressBar.tsx` (the always-on page-info footer, `.progressinfo`) rendered as `<div role='presentation' tabIndex={-1} onClick=...>`. The `tabIndex={-1}` made the decorative div click/touch-focusable. Android WebView long-press focused it and painted the browser default focus ring (`outline: auto`, matched `:focus-visible`). It's a top-document element pinned `absolute bottom-0` at book-view width, so the ring shows as a content-width box at the bottom on *every* page until focus clears. NOT a range/slider input — those are `opacity-0`/`visibility:hidden` (red herring: globals.css excludes `input[type='range']` from its outline-suppression rule, but that's unrelated here).
|
||||
|
||||
**Fix:** remove `tabIndex={-1}`. A `role='presentation'` decorative element must not be focusable. `onClick` (tap-to-cycle progress mode / dismiss annotation popup) fires regardless of tabindex, and no ancestor has a real `tabindex` to grab focus instead (verified: focus falls through to `<body>`). Nothing programmatically focuses `.progressinfo`. Test asserts `.progressinfo` has no `tabindex` attribute (in `ProgressBar.test.tsx`).
|
||||
|
||||
**Debugging technique that worked:** download the issue video (`gh` attachment URL) → `ffmpeg` extract frames → zoom on the line; the **downward corner at the right end** revealed it was a rectangular *outline* (focus box), not a strikethrough/selection. Then in the live dev app (`mcp__claude-in-chrome__javascript_tool`): `el.focus(); el.matches(':focus-visible'); getComputedStyle(el).outlineStyle` → returned `true` / `auto`, confirming the element + mechanism. See [[annotator-reader-fixes]] [[layout-ui-fixes]].
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
name: reading-ruler-line-aware
|
||||
description: "Line-aware/column-aware reading ruler — geometry pipeline, key files, and the Range.getClientRects block-box gotcha"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f55c9971-c85f-44af-82c3-822e3bbd1129
|
||||
---
|
||||
|
||||
Reading ruler (`src/app/reader/components/ReadingRuler.tsx`, pure logic in
|
||||
`src/app/reader/utils/readingRuler.ts`) snaps to real rendered text lines instead of
|
||||
stepping by a fixed arithmetic height. MERGED to main via PR #4358 (squash); the
|
||||
`feat/line-aware-reading-ruler` branch + worktree are gone. Spec/plan under
|
||||
`apps/readest-app/docs/superpowers/`.
|
||||
|
||||
Geometry pipeline:
|
||||
- Line geometry comes from `progress.range.getClientRects()` (the foliate relocate range
|
||||
spans first-to-last visible text). Single column + vertical mode → `buildLineBoxes` +
|
||||
`snapReadingRulerToLines` (full-width band). Multi-column horizontal → `buildReadingRulerColumns`
|
||||
+ `snapReadingRulerColumns`, band spans one column, dims the rest incl. the other column.
|
||||
- The snap functions return the real line-block extent `{start,end}`; the band is sized
|
||||
dynamically to that block + symmetric padding (`calculateReadingRulerPadding` =
|
||||
`round(fontSize*lineHeight*0.4)`) so padding is equal all around. `currentPosition` stays
|
||||
the band center % (drag/persistence); `bandSize` state holds the dynamic thickness, falling
|
||||
back to `baseRulerSize + 2*padding` for scrolled/fixed-layout/drag/unmeasured.
|
||||
- Multi-column detection: `view.renderer.columnCount` (already on the `Renderer` type).
|
||||
- Snapping works in scrolled mode too (`supportsLineSnap` no longer excludes scrolled).
|
||||
Single-column geometry goes through `buildVisibleLineBoxes` → frame-offset mapping, which
|
||||
is REQUIRED in scrolled mode (the visible section iframe is offset vertically by the scroll;
|
||||
there are several stacked section iframes, frame tops far negative). Auto-move stays disabled
|
||||
in scrolled mode (no jump on scroll); the band derives from the saved position on mount.
|
||||
- GOTCHA: in scrolled mode `progress.range` (the relocate range) covers only PART of the
|
||||
viewport, so using it would make the band hit a false end mid-view and scroll early (skipping
|
||||
lines). Scrolled mode instead builds boxes from the visible section(s) directly
|
||||
(`buildScrolledLineBoxes`: walk `view.renderer.getContents()`, `selectNodeContents(body)`,
|
||||
frame-offset map, filter to viewport). At a view edge a tap sets `pendingScrollAlignRef`; the
|
||||
view scrolls and the next relocate realigns the band to the first (forward) / last (backward)
|
||||
group. `filterVisibleLineBoxes` (≥0.5 visible) keeps the mount/realign placement on-screen.
|
||||
- GOTCHA: do NOT call `renderer.scrollBy()` yourself to advance the band in scrolled mode — a
|
||||
manual scroll that crosses a section boundary fires the relocate MID-relayout, so
|
||||
`getContents()` frame offsets are stale and `buildScrolledLineBoxes` returns garbage (huge
|
||||
blocks spanning gaps) → the band loops in place. Instead, when the next block can't be
|
||||
centered, return false so foliate page-scrolls (its relocate fires AFTER layout settles) and
|
||||
realign via `pendingScrollAlignRef`. Band is always centered on its block (no center clamp).
|
||||
- Band height is capped at `calculateReadingRulerSize(lines + 1, …)` so a tall element (e.g. a
|
||||
full-page image) inside the snapped block can't expand the band to cover the whole image.
|
||||
- **Coordinate mapping**: paginated multi-column pages shift the iframe far off-screen
|
||||
horizontally (`frameRect.left` was -4773 in testing); map iframe-content rects to overlay
|
||||
coords with the iframe `frameElement` offset (`mapRangeRectsToOverlay`), not just
|
||||
`rect.top - containerRect.top`.
|
||||
|
||||
**GOTCHA (caused a paragraph-skip bug):** `Range.getClientRects()` aggregates the border
|
||||
boxes of every *fully-enclosed element*, so multi-line `<p>`/container blocks return rects
|
||||
far taller than a text line (e.g. h=410 spanning a whole paragraph) alongside the line
|
||||
rects. An overlap-based line merge chains those into one giant "line", making the snap skip
|
||||
an entire paragraph — worse with more paragraphs per column. Fix: `dropBlockRects()` discards
|
||||
rects whose cross-axis thickness > 1.8× the median line thickness before clustering.
|
||||
|
||||
**Vertical key mapping**: in vertical writing mode only Up/Down arrows move the ruler;
|
||||
Left/Right turn pages (`isReadingRulerMoveKey(side, isVertical)` in `readingRuler.ts`, gating
|
||||
`moveReadingRuler` in `useBookShortcuts.ts`). Taps always move the ruler regardless of the
|
||||
tapped side (the restriction is keyboard-only; the tap path in `usePagination.ts` is untouched).
|
||||
|
||||
**Scrolled vertical-rl gotcha**: scrolled vertical scrolls HORIZONTALLY (section iframes stack
|
||||
along x; all share top/bottom). `progress.range` covers only the left ~30% of the visible width,
|
||||
so building boxes from it makes the edge realign land mid-view. `buildScrolledLineBoxes` is now
|
||||
vertical-aware (horizontal frame-visibility filter `frame.right<=cont.left || frame.left>=cont.right`,
|
||||
`buildLineBoxes(mapped, isVertical, …)`) and the scrolled edge-scroll path (`buildScrolledLineBoxes`
|
||||
choice, centerable check) no longer excludes vertical (`scrolled = !!viewSettings.scrolled`, was
|
||||
`&& !isVertical`). At the last column, advancing scrolls the view forward and snaps the band to the
|
||||
first (rightmost) column; backward snaps to the last (leftmost). PAGINATED vertical already paged at
|
||||
the boundary (snap returns null → handler returns false → keyboard `viewPagination` → `view.next()`;
|
||||
auto-move effect places the band on the new page's first column).
|
||||
|
||||
Verified live with Chrome MCP by reconstructing the column pipeline in-page and dispatching
|
||||
ArrowRight/ArrowLeft. Note: rapid synthetic key events in a tight loop get coalesced/throttled
|
||||
by the reader's nav handling — drive one press per tool call with real time between. To inspect
|
||||
logged objects (read_console_messages shows "Object"), monkey-patch console.log in-page to push
|
||||
JSON.stringify'd args into a window array, then read that array.
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
name: Share-a-Book Feature (in progress)
|
||||
description: Active implementation of /s/{token} share links + cherry-picks (CFI auto-include, 1-tap library import, branded OG images). All locked decisions and the plan file path.
|
||||
type: project
|
||||
originSessionId: e4ddc690-b1a9-4557-855f-d4e67055824f
|
||||
---
|
||||
Active feature on branch `dev` as of 2026-05-02. Plan file: `/Users/chrox/.claude/plans/ok-we-will-learn-cosmic-acorn.md` (always read for source of truth before continuing).
|
||||
|
||||
**Why:** complement just-shipped annotation deep-links (PRs #4018, #4019) with whole-book sharing. Cloud-sync infra exists; this layers public time-limited links on top.
|
||||
|
||||
**How to apply:** these decisions are locked across CEO + eng + design reviews. Do NOT re-litigate when implementing.
|
||||
|
||||
## Locked decisions (authoritative)
|
||||
|
||||
- **Universal 7-day cap** on share expiry, no tier differentiation, no "Never". All users pick `[1, 3, 7]` days. DMCA-risk reduction.
|
||||
- **App Router** for all share routes (mirrors `/o` precedent + recent Stripe / AI / IAP / OPDS). Pages API `storage/*` neighbors stay where they are.
|
||||
- **Single non-dynamic landing page** at `src/app/s/page.tsx` + rewrite `'/s/:token' → '/s?token=:token'` in `next.config.mjs`. Mirrors `/o`'s pattern exactly. Avoids `[token]` dynamic-segment trap under `output: 'export'`.
|
||||
- **R2 server-side byte-copy** for `/import` (recipient-side library import). NOT a reference. Preserves invariant that every `files` row's `file_key` starts with that row's `user_id` — keeps stats / purge / delete / download routes working unchanged.
|
||||
- **`token_hash` (sha256) in DB**, never the raw token. Raw token shown to user once at create.
|
||||
- **Live `(user_id, book_hash)` resolution** at every access (no FK to `files`). Re-uploads of the same hash follow the share automatically.
|
||||
- **GET `/download` is a 302 redirect with NO DB writes**. Count via separate `POST /download/confirm` so unfurlers / prefetchers can't inflate counts.
|
||||
- **Atomic SQL `download_count = download_count + 1`**, not read-modify-write.
|
||||
- **Per-user 50-share cap**, enforced at create-time.
|
||||
- **Auth detection on /s landing**: server-render via Supabase auth cookie in `app/s/layout.tsx`. No layout shift. Falls back to anonymous flow + post-hydration upgrade if SSR fails.
|
||||
- **Cover-less `og.png` fallback**: text-only display-type card. NO placeholder rectangle, NO procedural pattern.
|
||||
- **Universal Links / App Links**: handle `https://web.readest.com/s/...` in v1 via `useOpenShareLink`. Tauri's `applinks` config already covers the host.
|
||||
|
||||
## Cherry-picks accepted (CEO review SELECTIVE EXPANSION)
|
||||
|
||||
1. **Position-aware share** — every share initiated from the reader auto-includes `cfi`; recipient lands at sharer's paragraph. Toggle in dialog (default on, only visible when reader-context).
|
||||
3. **1-tap "Add to my library"** — logged-in recipient on `/s` gets primary action that calls `/import` (R2 byte-copy) and navigates to `/reader?ids=...&cfi=...`.
|
||||
4. **Branded OG image** — `/api/share/[token]/og.png` server-renders via `@vercel/og`. Spec includes anti-slop checklist.
|
||||
|
||||
Deferred to TODOS.md: QR code on landing, notify-on-download toggle.
|
||||
|
||||
## Open implementation questions (resolve mid-build)
|
||||
|
||||
1. Upload-confirmation: HEAD R2 in `/create`, or add `uploaded_at` column to `files`? — recommend HEAD.
|
||||
2. Migration directory: project has no `apps/readest-app/supabase/migrations/`. Confirm where SQL actually lands before writing the file.
|
||||
3. `@vercel/og` runtime compat with CloudFlare Workers (OpenNextJS). Verify before relying on it; fallback to Satori + sharp if incompatible.
|
||||
4. App Router route handlers under `src/app/api/share/...` should be silently dropped by `output: 'export'` in Next 16.2.3. Confirm during first Tauri build.
|
||||
|
||||
## Critical files for implementation
|
||||
|
||||
See "Critical Files (modify or create)" table in the plan. Key starting points:
|
||||
- `src/libs/storage-server.ts` — extract `getDownloadSignedUrl` from `pages/api/storage/download.ts`
|
||||
- `src/libs/share-server.ts` (new) — `generateShareToken()`, `hashShareToken(raw)`
|
||||
- `src/libs/share.ts` (new) — typed client used by dialog, manage section, deeplink hook, landing page
|
||||
- `src/utils/share.ts` (new) — `buildShareUrl(token)`, `parseShareDeepLink(url)`
|
||||
- Dialog reference: `src/components/Dialog.tsx`, `BookDetailModal.tsx`
|
||||
- Landing reference: `src/app/o/page.tsx` (lift `Card`, `BrandHeader`, `PageFooter` to `src/components/landing/`)
|
||||
- Deeplink hook reference: `src/hooks/useOpenAnnotationLink.ts`, `useOpenWithBooks.ts`
|
||||
- App-level upload entry: `appService.uploadBook(book)` at `src/services/appService.ts:269` (NOT `cloudService.uploadBook` — lower-level fn)
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: table-dark-mode-tint-4419
|
||||
description: "Dark-mode table tint must stay gated on overrideColor; blanket `table *` color-mix breaks plain tables AND vertical-TOC spacer cells"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 114eeb22-c9b6-480d-8305-3a3190855638
|
||||
---
|
||||
|
||||
`getColorStyles` in `src/utils/style.ts` has a rule:
|
||||
`blockquote, table * { background-color: color-mix(in srgb, ${bg} 80%, #000) }` in dark mode.
|
||||
The `table *` part **must** be gated on `overrideColor` — `${isDarkMode && overrideColor ? ...}`.
|
||||
|
||||
**This gate has regressed twice.** #2377 (PR #2379) first added it ("avoid overriding
|
||||
table background by default"). #4055 (commit `cead0f42e`, closes #4028/#4029) **removed**
|
||||
the gate to darken illegible white zebra-stripe rows — which re-broke it and caused #4419:
|
||||
*every* dark-mode table (and its cells) gets a tint a few shades off the page bg, even
|
||||
tables with no background of their own.
|
||||
|
||||
Why the gate is safe now: #4392 (`176b950c9`) added light-background rewriters that map
|
||||
only actually-light backgrounds → page `bg` in dark mode, regardless of overrideColor —
|
||||
`getDarkModeLightBackgroundOverrides` (inline styles) + the `transformStylesheet` dark-mode
|
||||
block (stylesheet rules, `isLightCssColor` luminance > 0.85). So #4028's white zebra rows
|
||||
stay legible without the blanket tint. The blockquote-only tint (standalone `blockquote {}`
|
||||
rule, from #2538) stays unconditional in dark mode — that's intended.
|
||||
|
||||
**Symptom #2 shares this root cause.** #4419 also reported "spacing between words changes"
|
||||
on a vertical (writing-mode) 目录/TOC page. Those CJK web-novel books lay the TOC out as a
|
||||
`<table>` with empty `<td>` spacer columns and `<span class="space">▉</span>` spacers
|
||||
(custom "space" font; ▉ U+2589 is a **blank glyph, contours=0** — pure advance width, no
|
||||
outline). The blanket `table *` rule paints a background on those spacer spans/cells,
|
||||
making the invisible gaps show as tinted blocks → looks like the spacing changed. It's the
|
||||
painted **background**, not text color, that reveals them — recoloring `.co0` (`color:#000`
|
||||
→ fg) does nothing because the glyph has no outline. Gating the tint fixes both symptoms.
|
||||
|
||||
Tests live in `src/__tests__/utils/style-get-styles.test.ts` (assert the `blockquote,
|
||||
table *` block has no `color-mix` when overrideColor false; keep the override-true and
|
||||
blockquote-still-tinted cases). Related: [[css-style-fixes]].
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
name: tauri-menu-append-race-4389
|
||||
description: "Un-awaited Tauri Menu.append() races on IPC → context menu items shuffle order randomly; fix = single Menu.new({ items })"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 8169b903-f66e-4c35-b90f-6b9110837588
|
||||
---
|
||||
|
||||
#4389 — the bookshelf right-click context menu (Windows/Edge WebView2) showed the
|
||||
same items but in a **randomly changing order** on every open.
|
||||
|
||||
**Root cause:** `Menu.append()` from `@tauri-apps/api/menu` returns `Promise<void>`
|
||||
— each call is an async IPC round-trip to the Rust backend. `BookshelfItem.tsx`
|
||||
fired ~11 `menu.append(item)` calls **without awaiting** (then `menu.popup()` also
|
||||
un-awaited). The concurrent IPC requests resolve in non-deterministic order on the
|
||||
Rust side, so items land shuffled. Synchronous JS call order is correct — the race
|
||||
is purely in async resolution, which is why it's invisible in jsdom and only shows
|
||||
on the native app.
|
||||
|
||||
**Fix:** build the items array in order and create the menu in ONE call:
|
||||
`const menu = await Menu.new({ items })` then `await menu.popup()`.
|
||||
`MenuOptions.items` / `append()` accept plain `MenuItemOptions` (`{ text, action }`)
|
||||
arrays — no need to pre-create `MenuItem.new()` per item at all. Applied to both
|
||||
book and group handlers.
|
||||
|
||||
**Testability:** extracted the order/inclusion logic into a pure
|
||||
`getBookContextMenuItemIds(book): BookContextMenuItemId[]` in
|
||||
`src/app/library/utils/libraryUtils.ts` (dep-light, already test-covered) so the
|
||||
deterministic ordering is unit-testable without mounting the component or mocking
|
||||
Tauri. Test: `src/__tests__/app/library/book-context-menu.test.ts`. The pure fn
|
||||
guards the order contract; the `Menu.new({ items })` wiring is what kills the race.
|
||||
|
||||
**General lesson:** any un-awaited sequence of Tauri/IPC mutations that must stay
|
||||
ordered (append, insert, etc.) is a latent race — batch into one call or await each.
|
||||
See [[bug-patterns]].
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
name: tauri-parser-parity-tests
|
||||
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 90d14df3-59ce-438f-ae91-b72e9d2b6f99
|
||||
---
|
||||
|
||||
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
|
||||
|
||||
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
|
||||
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
|
||||
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
|
||||
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
|
||||
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
|
||||
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
|
||||
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
|
||||
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
|
||||
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
|
||||
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
|
||||
|
||||
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
|
||||
- `pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
|
||||
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
|
||||
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
|
||||
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
|
||||
|
||||
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified` → `published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
name: toc-expand-and-autoscroll
|
||||
description: TOC sidebar auto-expansion policy + the Virtuoso scrollToIndex-after-expansion race that breaks the initial scroll-to-current-chapter
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 4d4eefcb-324a-4342-9472-9dae7cc57b41
|
||||
---
|
||||
|
||||
Issue #4059: the TOC sidebar used to auto-expand *every* top-level container (`computeExpandedSet` added all `toc.filter(item => item.subitems?.length)`), so multi-volume books ("文学必读合集20册") opened fully expanded. Fix: expand only the current location's ancestor path (`findParentPath`), with a single-root fallback (expand `toc[0]` only when `toc.length === 1`) so a one-root-wrapper TOC isn't reduced to one row. Pure logic lives in `src/app/reader/components/sidebar/tocTree.ts` (extracted from TOCView so it's unit-testable — TOCView itself can't be imported in jsdom: react-virtuoso + OverlayScrollbars CSS).
|
||||
|
||||
Collapsing-by-default introduced a **regression**: the current chapter no longer scrolled into view on fresh load. Root cause is two-fold, both stemming from the pinned sidebar mounting *before* the first relocate (progress is null at mount, so `initialScrollTarget.index` is 0 and Virtuoso's `initialTopMostItemIndex` can't anchor):
|
||||
|
||||
1. When progress arrives, the current volume expands and the virtualized list grows by dozens of rows in one commit. Virtuoso emits a synthetic `onScroll`; the old handler treated *any* scroll while a scroll was queued as "user took over" and cleared `pendingScrollRef`. Fix: ignore a queued-state scroll unless a real gesture preceded it — track `userInputRef` via capture-phase `wheel`/`touchstart`/`pointerdown`/`keydown` listeners on the scroller; `if (pendingScrollRef.current && !userInputRef.current) return;`.
|
||||
2. Even with pending preserved, a single `scrollToIndex(idx, {align:'center'})` fired in the same commit as the 28→90 row growth **lands short** — Virtuoso scrolls before measuring the new rows. Fix: re-assert the scroll on the next `requestAnimationFrame` (only for the instant `behavior:'auto'` case). The toggle-sidebar (remount) path always worked because remounting feeds `initialTopMostItemIndex` before first render.
|
||||
|
||||
Verified live: 红楼梦 book at `/reader/217e3f1e...` — only 下卷 expanded, current chapter centered (0px from viewport center), user scroll no longer snaps back. Related: [[issue-4112-scroll-anchoring]], [[reading-ruler-line-aware]], [[virtuoso_overlayscrollbars]].
|
||||
@@ -1,50 +0,0 @@
|
||||
# TTS (Text-to-Speech) Fixes Reference
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
- `TTSController` (`src/services/tts/TTSController.ts`) - Core state machine
|
||||
- `EdgeTTSClient` (`src/services/tts/EdgeTTSClient.ts`) - Edge TTS provider
|
||||
- `useTTSControl` hook (`src/app/reader/hooks/useTTSControl.ts`) - React integration
|
||||
- `useTTSMediaSession` hook (`src/app/reader/hooks/useTTSMediaSession.ts`) - Media controls
|
||||
|
||||
### Section-Aware TTS Model
|
||||
TTS tracks its own section independently from the view via `#ttsSectionIndex`:
|
||||
- `#initTTSForSection()` - Creates TTS document for a section without changing the view
|
||||
- `#initTTSForNextSection()` / `#initTTSForPrevSection()` - Navigate TTS across sections
|
||||
- `#getHighlighter()` - Only returns highlighter if view section matches TTS section
|
||||
- `onSectionChange` callback - Notifies UI when TTS crosses section boundary
|
||||
- Highlights use CFI strings (not raw Range objects) for cross-section compatibility
|
||||
|
||||
### State Management Pitfalls
|
||||
1. **`#ttsSectionIndex` must match view section for highlights to work**
|
||||
- If `-1`, all highlight calls are suppressed
|
||||
- `shutdown()` sets it to `-1` but must also null out `this.view.tts`
|
||||
|
||||
2. **Guards/Refs that block re-entry:**
|
||||
- The old `ttsOnRef` guard blocked TTS restart from annotations (removed in #3292)
|
||||
- `view.tts` reference surviving shutdown blocked re-initialization (#3400)
|
||||
|
||||
3. **Timeouts that fire after pause:**
|
||||
- Edge TTS had a safety timeout that advanced sentences even when paused (#3244)
|
||||
- Solution: removed the entire `ontimeupdate` safety timeout mechanism
|
||||
|
||||
## Fix History
|
||||
|
||||
| Issue | Problem | Root Cause | Fix |
|
||||
|-------|---------|------------|-----|
|
||||
| #3100 | TTS scrolls too far | TTS coupled to view section | Added `#ttsSectionIndex`, "Back to TTS Location" button |
|
||||
| #3198 | TTS doesn't follow to next section | No `onSectionChange` callback | Added section change notification, extracted hooks |
|
||||
| #3244 | Paused TTS advances | Safety timeout fires after pause | Removed `ontimeupdate` timeout mechanism |
|
||||
| #3291 | TTS fails without lang attribute | Invalid SSML from missing lang | Set lang/xml:lang on html element from `ttsLang` |
|
||||
| #3292 | Can't restart TTS from annotation | `ttsOnRef` blocks re-entry | Removed the guard ref entirely |
|
||||
| #3400 | TTS highlight stops after restart | `view.tts` not nulled on shutdown | Added `this.view.tts = null` in `shutdown()` |
|
||||
|
||||
## Debugging TTS Issues
|
||||
|
||||
1. **TTS doesn't start:** Check `#initTTSForSection()` - does `view.tts.doc === doc` shortcut early?
|
||||
2. **No highlights:** Check `#ttsSectionIndex` matches view's section index
|
||||
3. **Advances when paused:** Look for setTimeout/timer callbacks that bypass pause state
|
||||
4. **Can't restart:** Check for refs/guards that prevent re-entry into speak handlers
|
||||
5. **Fails on some chapters:** Check if chapter has lang attribute and XHTML namespace
|
||||
6. **SSML errors:** Check `src/utils/ssml.ts` for proper namespace/lang handling
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: txt-author-recognition-4390
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f151827f-0bf2-4307-ac92-e6077df54d19
|
||||
---
|
||||
|
||||
Issue #4390: imported Chinese web-novels showed author either **missing (未知)** or
|
||||
as an **irrelevant metadata blob** (e.g. `2024/08/01发表于:是否首发:是字数1023150字116:01`).
|
||||
|
||||
**Key debugging tell:** the displayed *title* was the **entire filename** including
|
||||
`作者:X` (e.g. `【月如无恨月长圆】(1-154)作者:陈西`). A real EPUB's `dc:title` would
|
||||
be just the book name. Full-filename title ⇒ the file went through Readest's
|
||||
**TXT→EPUB converter** (`bookService.ts` `/\.txt$/` → `TxtToEpubConverter`), which
|
||||
sets `bookTitle = base filename` in the no-`《》` branch of `extractTxtFilenameMetadata`.
|
||||
So "EPUB format" books can still be TXT-origin — check `src/utils/txt.ts`, not foliate-js,
|
||||
when title looks like a filename. (Native Rust EPUB parser #4369 was NOT shipped in 0.11.2.)
|
||||
|
||||
Two root causes in `src/utils/txt.ts`:
|
||||
1. `extractTxtFilenameMetadata` only pulled an author from `《》`-wrapped names; `【】`
|
||||
names (the web-novel norm) got no filename author.
|
||||
2. Greedy header capture `/[【\[]?作者[】\]]?[::\s]\s*(.+)\r?\n/` grabbed a whole noisy
|
||||
metadata line as the author.
|
||||
|
||||
Fix:
|
||||
- `parseLabeledAuthor(base)` extracts the labeled `作者:X` from ANY filename; title stays
|
||||
the full name. Only the *labeled* form is safe on a full filename — a bracketed/bare
|
||||
fallback would mistake a leading `【title】` for the author.
|
||||
- `isPlausibleAuthorName()` rejects a header match that looks like a blob (contains `:`/`:`,
|
||||
a `\d{4,}` run, or length > 20) → falls back to the filename author. Applied in BOTH
|
||||
`convertSmallFile` and `extractAuthorAndLanguage` (large-file path).
|
||||
|
||||
Note: the `著` form is handled for file *content* headers but NOT for filenames (kept minimal).
|
||||
Reported files were 455 kB / 1.25 MB → both under the 8 MB `LARGE_TXT_THRESHOLD_BYTES`
|
||||
(`convertSmallFile` path). Tests live in `txt-converter.test.ts` (faithful repro of both
|
||||
reported strings). Related: [[bug-patterns]].
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
name: Virtuoso + OverlayScrollbars pattern
|
||||
description: How to integrate OverlayScrollbars with react-virtuoso for overlay scrollbars on Android/iOS webviews
|
||||
type: reference
|
||||
originSessionId: 9da59a46-3dff-4a77-b7a4-8de4d07297b6
|
||||
---
|
||||
Virtuoso manages its own internal scroller. On Android WebView (and similar) native scrollbars auto-hide, so users see no scrollbar. The fix: wrap Virtuoso with OverlayScrollbars using the `useOverlayScrollbars` hook — **not** the `OverlayScrollbarsComponent`.
|
||||
|
||||
## Migration from `customScrollParent`
|
||||
|
||||
The previous approach used `customScrollParent` to let an outer `OverlayScrollbarsComponent` own the scroll. This was replaced: Virtuoso now owns its own scroller, and OverlayScrollbars wraps it. This means:
|
||||
- Remove `customScrollParent` prop from Virtuoso/VirtuosoGrid
|
||||
- Remove the outer `OverlayScrollbarsComponent` wrapper
|
||||
- Use `scrollerRef` instead to capture Virtuoso's scroller element
|
||||
- If the parent needs the scroller ref (e.g. for pull-to-refresh, scroll save/restore), expose it via a callback prop like `onScrollerRef`
|
||||
|
||||
## Boilerplate
|
||||
|
||||
```tsx
|
||||
import { useOverlayScrollbars } from 'overlayscrollbars-react';
|
||||
import 'overlayscrollbars/overlayscrollbars.css';
|
||||
|
||||
// Inside the component:
|
||||
const osRootRef = useRef<HTMLDivElement>(null);
|
||||
const [scroller, setScroller] = useState<HTMLElement | null>(null);
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
events: {
|
||||
initialized(instance) {
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = osRootRef.current;
|
||||
if (scroller && root) {
|
||||
initialize({ target: root, elements: { viewport: scroller } });
|
||||
}
|
||||
return () => osInstance()?.destroy();
|
||||
}, [scroller, initialize, osInstance]);
|
||||
|
||||
const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => {
|
||||
const div = el instanceof HTMLElement ? el : null;
|
||||
setScroller(div);
|
||||
// If parent needs the scroller (e.g. for pull-to-refresh):
|
||||
onScrollerRef?.(div as HTMLDivElement | null);
|
||||
}, [onScrollerRef]);
|
||||
```
|
||||
|
||||
## JSX structure
|
||||
|
||||
```tsx
|
||||
<div ref={osRootRef} data-overlayscrollbars-initialize='' className='h-full'>
|
||||
<Virtuoso
|
||||
scrollerRef={handleScrollerRef}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={items.length}
|
||||
itemContent={renderItem}
|
||||
overscan={200}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
For `VirtuosoGrid`, same pattern — pass `scrollerRef={handleScrollerRef}`.
|
||||
|
||||
## Footer spacer
|
||||
|
||||
When Virtuoso owns its own scroller (no `customScrollParent`), the last items may be hidden behind bottom UI (tab bars, safe area). Add a Virtuoso `Footer` component to the components config:
|
||||
|
||||
```tsx
|
||||
const VIRTUOSO_COMPONENTS = {
|
||||
List: MyListComponent,
|
||||
Footer: () => <div style={{ height: 34 }} />,
|
||||
};
|
||||
```
|
||||
|
||||
## Key points
|
||||
|
||||
- **`useOverlayScrollbars`** hook, not `OverlayScrollbarsComponent` — the component can't share a viewport with Virtuoso
|
||||
- Wrapper div needs `ref={osRootRef}` and `data-overlayscrollbars-initialize=""`
|
||||
- `initialize({ target: root, elements: { viewport: scroller } })` tells OverlayScrollbars to use Virtuoso's existing scroller as its viewport (no new DOM element)
|
||||
- The `initialized` event **must** restore overflow CSS vars (`--os-viewport-overflow-x/y`) so OverlayScrollbars doesn't fight Virtuoso's scroll management
|
||||
- No custom Scroller component needed — `scrollerRef` replaces the old `Scroller` component pattern (e.g. `TOCScroller` was removed)
|
||||
|
||||
## Used in
|
||||
|
||||
- `src/app/library/components/Bookshelf.tsx` — library grid/list with parent scroller exposure for pull-to-refresh and scroll save/restore
|
||||
- `src/app/reader/components/sidebar/TOCView.tsx` — sidebar TOC (self-contained, no parent scroller needed)
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: window-state-sanitize-4398
|
||||
description: Windows launch crash (WebView2 0x80070057) from invalid .window-state.json; defensive sanitizer plugin
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 40a168fd-c90e-4f00-9fbc-7effe7c0c45f
|
||||
---
|
||||
|
||||
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
|
||||
|
||||
Non-obvious facts:
|
||||
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
|
||||
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
|
||||
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
|
||||
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
|
||||
|
||||
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
## Test-First Development
|
||||
|
||||
- Always write a failing unit test **before** implementing a fix.
|
||||
- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes.
|
||||
- Run the full test suite (`pnpm test`) after changes to ensure no regressions.
|
||||
@@ -1,5 +0,0 @@
|
||||
## TypeScript
|
||||
|
||||
- Never use the `any` type. Use `unknown`, proper types, or generics instead.
|
||||
- Strict mode is enabled. Target is ES2022.
|
||||
- Unused vars prefixed with `_` are allowed (ESLint configured).
|
||||
@@ -1,9 +0,0 @@
|
||||
## Verification (done-conditions)
|
||||
|
||||
Before marking work complete, all applicable checks must pass:
|
||||
|
||||
1. `pnpm test` — unit tests (vitest)
|
||||
2. `pnpm lint` — Biome + tsgo (also runs `pnpm lint:lua` if luajit is installed)
|
||||
3. `pnpm test:lua` — busted unit tests for `apps/readest.koplugin/spec/` (only when koplugin Lua files changed; soft-skips when busted/luajit not installed)
|
||||
4. `pnpm fmt:check` — Rust format check (only when `src-tauri/` files changed)
|
||||
5. `pnpm clippy:check` — Rust lint (only when `src-tauri/` files changed)
|
||||
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.
|
||||
@@ -1,12 +1,3 @@
|
||||
PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
|
||||
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_DEV_BASE64="cGtfdGVzdF81MVJmQmdLRTdSWW5pTWsxc0tDV2RUd2hMZzcySzk4eDRWcjlIdDdsRFBONngzcnpZYmhydGtNQnpDdzZKbHFaRVVITVp5eVNjVXhCZXVkcGppWTk0WXNHcDAweFlRRnRRaUU="
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_BASE64="cGtfbGl2ZV81MVFYN3dRRU5ndjJFOUxQRHpZUlE5TlJJeTNjd09EZ1AzSkNFRHRPWlFtdFJWc3Brd053ZE1NNUpIVnVPTmJWcjZ3VGFCMUNZR1pJMmRPVWppTkY0bHJvVjAwalE4TkpkdWk="
|
||||
|
||||
@@ -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.”
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user