forked from akai/readest
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90fee08be2 |
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "Readest",
|
||||
// Initialize only the submodules required for Docker builds.
|
||||
// tauri/tauri-plugins are skipped here since they're only needed for desktop builds.
|
||||
"postCreateCommand": "git submodule update --init packages/foliate-js packages/simplecc-wasm"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Rust build artifacts
|
||||
target
|
||||
**/target
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Build outputs
|
||||
.next
|
||||
**/.next
|
||||
.open-next
|
||||
**/.open-next
|
||||
out
|
||||
**/out
|
||||
.vercel
|
||||
**/.vercel
|
||||
|
||||
# Local env files
|
||||
docker/.env
|
||||
.env*.local
|
||||
**/.env*.local
|
||||
|
||||
# Local credentials and tooling state
|
||||
*.pem
|
||||
certs
|
||||
**/certs
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/worktrees
|
||||
**/.claude/worktrees
|
||||
.gstack
|
||||
**/.gstack
|
||||
.playwright-mcp
|
||||
**/.playwright-mcp
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
@@ -1,3 +0,0 @@
|
||||
[*.{ts,tsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -1,15 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: ['readest']
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -1,23 +1,19 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Share an idea or suggestion
|
||||
title: 'FR: describing your feature request'
|
||||
title: 'FR: [a handful of words describing the FR]'
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Does your feature request involve difficulty for you to complete a task? Please describe.**
|
||||
|
||||
> A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
**Does your feature request involve difficulty completing a task? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I think it takes too many steps to [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
|
||||
> A clear and concise description of what you'd like to happen.
|
||||
A clear and concise description of what you'd like to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
|
||||
> Add any additional context or screenshots about the feature request here.
|
||||
Add any additional context or screenshots about the feature request here.
|
||||
|
||||
@@ -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
|
||||
+53
-333
@@ -5,28 +5,21 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
get-release:
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.get-release.outputs.release_id }}
|
||||
release_tag: ${{ steps.get-release.outputs.release_tag }}
|
||||
release_note: ${{ steps.get-release-notes.outputs.release_note }}
|
||||
release_version: ${{ steps.get-release-notes.outputs.release_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@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
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.getLatestRelease({
|
||||
@@ -34,10 +27,9 @@ jobs:
|
||||
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
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
@@ -47,77 +39,8 @@ jobs:
|
||||
const notes = releaseNotes.notes || [];
|
||||
const releaseNote = notes.map((note, index) => `${index + 1}. ${note}`).join(' ');
|
||||
console.log('Formatted release note:', releaseNote);
|
||||
core.setOutput('release_version', version);
|
||||
core.setOutput('release_note', releaseNote);
|
||||
|
||||
update-release:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: get-release
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
env:
|
||||
release_id: ${{ needs.get-release.outputs.release_id }}
|
||||
release_tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
release_note: ${{ needs.get-release.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
const { data } = await github.rest.repos.generateReleaseNotes({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag_name: process.env.release_tag,
|
||||
})
|
||||
const notes = process.env.release_note.split(/\d+\.\s/).filter(Boolean);
|
||||
const formattedNotes = notes.map(note => `* ${note.trim()}`).join("\n");
|
||||
const body = `## Release Highlight\n${formattedNotes}\n\n${data.body}`;
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: body,
|
||||
draft: false,
|
||||
prerelease: false
|
||||
})
|
||||
|
||||
build-koreader-plugin:
|
||||
needs: get-release
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: create KOReader plugin zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
plugin_zip="Readest-${version}-1.koplugin.zip"
|
||||
meta_file="apps/readest.koplugin/_meta.lua"
|
||||
perl -i -pe "s/^}/ version = \"${version}\",\n}/" "${meta_file}"
|
||||
|
||||
# Exclude dev-only artifacts from the published plugin zip:
|
||||
# scripts/ — i18n + build helpers
|
||||
# docs/ — design notes
|
||||
# spec/ — busted test suite
|
||||
# .busted — busted runner config
|
||||
# Mirror these in apps/readest.koplugin/scripts/build-koplugin.mjs
|
||||
# for local builds.
|
||||
cd apps
|
||||
zip -r ../${plugin_zip} readest.koplugin \
|
||||
-x 'readest.koplugin/scripts/*' \
|
||||
'readest.koplugin/docs/*' \
|
||||
'readest.koplugin/spec/*' \
|
||||
'readest.koplugin/.busted'
|
||||
cd ..
|
||||
|
||||
echo "Uploading ${plugin_zip} to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} ${plugin_zip} --clobber
|
||||
|
||||
build-tauri:
|
||||
needs: get-release
|
||||
permissions:
|
||||
@@ -126,193 +49,76 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- os: ubuntu-latest
|
||||
release: android
|
||||
rust_target: aarch64-linux-android,armv7-linux-androideabi,i686-linux-android,x86_64-linux-android
|
||||
- os: ubuntu-22.04
|
||||
release: linux
|
||||
arch: x86_64
|
||||
rust_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-22.04-arm
|
||||
release: linux
|
||||
arch: aarch64
|
||||
rust_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
release: macos
|
||||
arch: aarch64
|
||||
rust_target: x86_64-apple-darwin,aarch64-apple-darwin
|
||||
args: '--target universal-apple-darwin'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: x86_64
|
||||
rust_target: x86_64-pc-windows-msvc
|
||||
args: '--target x86_64-pc-windows-msvc --bundles nsis'
|
||||
args: '--target x86_64-pc-windows-msvc'
|
||||
- os: windows-latest
|
||||
release: windows
|
||||
arch: aarch64
|
||||
rust_target: aarch64-pc-windows-msvc
|
||||
args: '--target aarch64-pc-windows-msvc --bundles nsis'
|
||||
|
||||
runs-on: ${{ matrix.config.os }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: initialize git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9.14.4
|
||||
|
||||
- name: setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: setup Java (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
|
||||
with:
|
||||
distribution: 'zulu'
|
||||
java-version: '17'
|
||||
|
||||
- name: setup Android SDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4
|
||||
|
||||
- name: install NDK (for Android build only)
|
||||
if: matrix.config.release == 'android'
|
||||
run: sdkmanager "ndk;28.2.13676358"
|
||||
|
||||
- name: install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline
|
||||
run: pnpm install
|
||||
|
||||
- name: copy pdfjs-dist and simplecc-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-vendors
|
||||
- name: copy pdfjs-dist to public directory
|
||||
run: pnpm --filter @readest/readest-app setup-pdfjs
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.config.rust_target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
|
||||
|
||||
- name: install dependencies (ubuntu only)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.release != 'android' && matrix.config.arch != 'armhf'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev libgtk-3-dev libwebkit2gtk-4.1 libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev gir1.2-javascriptcoregtk-4.1 gir1.2-webkit2-4.1 libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
|
||||
- name: install dependencies (ubuntu only - armhf specific)
|
||||
if: contains(matrix.config.os, 'ubuntu') && matrix.config.arch == 'armhf'
|
||||
run: |
|
||||
sudo dpkg --add-architecture armhf
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libfontconfig-dev:armhf libgtk-3-dev:armhf libwebkit2gtk-4.1-dev:armhf libappindicator3-dev:armhf librsvg2-dev:armhf gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
echo 'PKG_CONFIG_ALLOW_CROSS=1' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig:/usr/share/pkgconfig' >> $GITHUB_ENV
|
||||
echo 'PKG_CONFIG_SYSROOT_DIR=/usr/arm-linux-gnueabihf' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc' >> $GITHUB_ENV
|
||||
echo 'CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUSTFLAGS=--cfg=io_uring_skip_arch_check' >> $GITHUB_ENV
|
||||
key: ${{ matrix.config.os }}-cargo-${{ hashFiles('apps/readest-app/src-tauri/Cargo.lock') }}
|
||||
workspaces: apps/readest-app/src-tauri -> target
|
||||
|
||||
- name: create .env.local file for Next.js
|
||||
run: |
|
||||
echo "NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.NEXT_PUBLIC_POSTHOG_HOST }}" >> .env.local
|
||||
echo "NEXT_PUBLIC_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-22.04'
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
rm -rf src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout .
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
pushd src-tauri/gen/android
|
||||
echo "keyAlias=${{ secrets.ANDROID_KEY_ALIAS }}" > keystore.properties
|
||||
echo "password=${{ secrets.ANDROID_KEY_PASSWORD }}" >> keystore.properties
|
||||
base64 -d <<< "${{ secrets.ANDROID_KEY_BASE64 }}" > $RUNNER_TEMP/keystore.jks
|
||||
echo "storeFile=$RUNNER_TEMP/keystore.jks" >> keystore.properties
|
||||
|
||||
popd
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
apk_path=src-tauri/gen/android/app/build/outputs/apk/universal/release
|
||||
universial_apk=Readest_${version}_universal.apk
|
||||
arm64_apk=Readest_${version}_arm64.apk
|
||||
pnpm tauri android build
|
||||
cp ${apk_path}/app-universal-release.apk $universial_apk
|
||||
pnpm tauri android build -t aarch64
|
||||
cp ${apk_path}/app-universal-release.apk $arm64_apk
|
||||
|
||||
echo "Uploading $universial_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk --clobber
|
||||
echo "Uploading $arm64_apk to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk --clobber
|
||||
echo "Uploading signatures to GitHub release"
|
||||
pnpm tauri signer sign $universial_apk
|
||||
pnpm tauri signer sign $arm64_apk
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $universial_apk.sig --clobber
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} $arm64_apk.sig --clobber
|
||||
|
||||
- name: download and update latest.json for Android release
|
||||
if: matrix.config.release == 'android'
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
# 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 +127,32 @@ 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 }}
|
||||
releaseId: ${{ needs.get-release.steps.get-release.outputs.release_id }}
|
||||
releaseBody: ${{ needs.get-release.steps.get-release-notes.outputs.release_note }}
|
||||
args: ${{ matrix.config.args || '' }}
|
||||
|
||||
- name: upload release notes to GitHub release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Uploading release notes to GitHub release"
|
||||
gh release upload ${{ needs.get-release.outputs.release_tag }} apps/readest-app/release-notes.json --clobber
|
||||
|
||||
- name: build and upload portable binaries (Windows only)
|
||||
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]
|
||||
update-release:
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/upload-to-r2.yml
|
||||
with:
|
||||
tag: ${{ needs.get-release.outputs.release_tag }}
|
||||
secrets: inherit
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: [get-release, build-tauri]
|
||||
|
||||
steps:
|
||||
- name: update release
|
||||
id: update-release
|
||||
uses: actions/github-script@v6
|
||||
env:
|
||||
release_id: ${{ needs.get-release.steps.get-release.outputs.release_id }}
|
||||
release_note: ${{ needs.get-release.steps.get-release-notes.outputs.release_note }}
|
||||
with:
|
||||
script: |
|
||||
github.rest.repos.updateRelease({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: process.env.release_id,
|
||||
body: process.env.release_note,
|
||||
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"
|
||||
@@ -4,20 +4,20 @@ on:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
|
||||
- uses: amondnet/vercel-action@v25
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-comment: false
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-args: '--prod'
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Create vercel preview URL on pull request
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
permissions:
|
||||
contents: write
|
||||
deployments: write
|
||||
pull-requests: write
|
||||
jobs:
|
||||
build_and_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'true'
|
||||
- uses: amondnet/vercel-action@v25
|
||||
id: vercel-deploy
|
||||
with:
|
||||
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
vercel-org-id: ${{ secrets.ORG_ID}}
|
||||
vercel-project-id: ${{ secrets.PROJECT_ID}}
|
||||
- name: preview-url
|
||||
run: |
|
||||
echo ${{ steps.vercel-deploy.outputs.preview-url }}
|
||||
-19
@@ -1,5 +1,4 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
docker/.env
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
@@ -36,21 +35,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Rust build
|
||||
target
|
||||
|
||||
fastlane/report.xml
|
||||
fastlane/metadata/android/en-US/changelogs
|
||||
|
||||
*.koplugin.zip
|
||||
|
||||
# nix
|
||||
result*
|
||||
|
||||
.playwright-mcp/
|
||||
.gstack
|
||||
|
||||
.claude/worktrees
|
||||
.claude/settings.local.json
|
||||
|
||||
|
||||
+2
-23
@@ -1,27 +1,6 @@
|
||||
[submodule "packages/foliate-js"]
|
||||
path = packages/foliate-js
|
||||
url = https://github.com/readest/foliate-js.git
|
||||
url = https://github.com/chrox/foliate-js.git
|
||||
[submodule "packages/tauri"]
|
||||
path = packages/tauri
|
||||
url = https://github.com/readest/tauri.git
|
||||
[submodule "packages/tauri-plugins"]
|
||||
path = packages/tauri-plugins
|
||||
url = https://github.com/readest/tauri-plugins-workspace.git
|
||||
[submodule "packages/simplecc-wasm"]
|
||||
path = packages/simplecc-wasm
|
||||
url = https://github.com/readest/simplecc-wasm.git
|
||||
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"]
|
||||
path = apps/readest-app/src-tauri/plugins/tauri-plugin-turso
|
||||
url = https://github.com/readest/tauri-plugin-turso.git
|
||||
[submodule "apps/readest-app/.claude/skills/gstack"]
|
||||
path = apps/readest-app/.claude/skills/gstack
|
||||
url = https://github.com/garrytan/gstack.git
|
||||
[submodule "packages/qcms"]
|
||||
path = packages/qcms
|
||||
url = https://github.com/mozilla/pdf.js.qcms.git
|
||||
[submodule "packages/js-mdict"]
|
||||
path = packages/js-mdict
|
||||
url = https://github.com/readest/js-mdict.git
|
||||
[submodule "apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade"]
|
||||
path = apps/readest-app/src-tauri/plugins/tauri-plugin-webview-upgrade
|
||||
url = https://github.com/readest/tauri-plugin-webview-upgrade.git
|
||||
url = https://github.com/chrox/tauri.git
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
pnpm exec lint-staged
|
||||
@@ -1,3 +0,0 @@
|
||||
pnpm -C apps/readest-app format:check
|
||||
pnpm -C apps/readest-app lint
|
||||
pnpm -C apps/readest-app test
|
||||
@@ -0,0 +1 @@
|
||||
packages/foliate-js/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode.vscode-typescript-next",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"biomejs.biome",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-26
@@ -4,29 +4,4 @@
|
||||
"packages/tauri/Cargo.toml",
|
||||
"apps/readest-app/src-tauri/Cargo.toml"
|
||||
],
|
||||
// "editor.formatOnSave": true, // uncomment to add format on save
|
||||
"typescript.inlayHints.parameterNames.enabled": "all",
|
||||
"typescript.inlayHints.variableTypes.enabled": true,
|
||||
"typescript.inlayHints.propertyDeclarationTypes.enabled": true,
|
||||
"typescript.inlayHints.functionLikeReturnTypes.enabled": true,
|
||||
"typescript.inlayHints.enumMemberValues.enabled": true,
|
||||
"javascript.validate.enable": false,
|
||||
"javascript.format.enable": false,
|
||||
"typescript.format.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,14 +3,14 @@
|
||||
When contributing to `Readest`, whether on GitHub or in other community spaces:
|
||||
|
||||
- Be respectful, civil, and open-minded.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/readest/readest/issues) for known issues or fixes.
|
||||
- Before opening a new pull request, try searching through the [issue tracker](https://github.com/chrox/readest/issues) for known issues or fixes.
|
||||
- If you want to make code changes based on your personal opinion(s), make sure you open an issue first describing the changes you want to make, and open a pull request only when your suggestions get approved by maintainers.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
### Prerequisites
|
||||
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/readest/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
In order to not waste your time implementing a change that has already been declined, or is generally not needed, start by [opening an issue](https://github.com/chrox/readest/issues/new/choose) describing the problem you would like to solve.
|
||||
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
@@ -33,7 +33,7 @@ To get started with Readest, follow these steps to clone and build the project.
|
||||
#### 1. Clone the Repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/readest/readest.git
|
||||
git clone https://github.com/chrox/readest.git
|
||||
cd readest
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
@@ -43,8 +43,8 @@ git submodule update --init --recursive
|
||||
```bash
|
||||
# might need to rerun this when code is updated
|
||||
pnpm install
|
||||
# copy vendors dist libs to public directory
|
||||
pnpm --filter @readest/readest-app setup-vendors
|
||||
# copy pdfjs-dist to Next.js public directory
|
||||
pnpm --filter @readest/readest-app setup-pdfjs
|
||||
```
|
||||
|
||||
#### 3. Verify Dependencies Installation
|
||||
@@ -75,7 +75,7 @@ Now you're all setup and can start implementing your changes.
|
||||
|
||||
### Implement your changes
|
||||
|
||||
This project is a monorepo. The code for the `readest-app` is in the `apps/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
This project is a monorepo. The code for the `readest-app` is in the `app/readest-app` directory. Here are some useful scripts for developing the frontend only without compiling Tauri:
|
||||
|
||||
| Command | Description |
|
||||
| ---------------- | -------------------------------------------------- |
|
||||
@@ -86,7 +86,7 @@ Recommended Visual Studio Code plugins for development:
|
||||
|
||||
- JavaScript and TypeScript Nightly (ms-vscode.vscode-typescript-next)
|
||||
- VS Code ESLint extension (dbaeumer.vscode-eslint)
|
||||
- Biome - Code formatter and linter (biomejs.biome)
|
||||
- Prettier - Code formatter (esbenp.prettier-vscode)
|
||||
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
|
||||
|
||||
### When you're done
|
||||
|
||||
Generated
-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,21 @@
|
||||
<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]
|
||||
[![][badge-discord]][link-discord]
|
||||
<br>
|
||||
[![Discord][badge-discord]][link-discord]
|
||||
[![Reddit][badge-reddit]][link-reddit]
|
||||
[![AGPL Licence][badge-license]](LICENSE)
|
||||
[![Language Coverage][badge-language-coverage]][link-locales]
|
||||
[![Donate][badge-donate]][link-donate]
|
||||
[![Latest release][badge-release]][link-gh-releases]
|
||||
[![Last commit][badge-last-commit]][link-gh-commits]
|
||||
[![Commits][badge-commit-activity]][link-gh-pulse]
|
||||
[![][badge-hellogithub]][link-hellogithub]
|
||||
[![Ask DeepWiki][badge-deepwiki]][link-deepwiki]
|
||||
|
||||
</div>
|
||||
|
||||
@@ -30,14 +25,13 @@
|
||||
<a href="#screenshots">Screenshots</a> •
|
||||
<a href="#downloads">Downloads</a> •
|
||||
<a href="#getting-started">Getting Started</a> •
|
||||
<a href="#troubleshooting">Troubleshooting</a> •
|
||||
<a href="#support">Support</a> •
|
||||
<a href="#contributors">Contributors</a> •
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://readest.com" target="_blank">
|
||||
<img src="./data/screenshots/landing_all_platforms.png" alt="Readest Banner" width="100%" />
|
||||
<img src="./data/screenshots/landing_preview.png" alt="Readest Banner" width="100%" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -45,38 +39,38 @@
|
||||
|
||||
<div align="left">✅ Implemented</div>
|
||||
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, TXT, PDF | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
|
||||
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
|
||||
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
|
||||
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
|
||||
| **Accessibility** | Provides full keyboard navigation and supports for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
|
||||
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
|
||||
| **Feature** | **Description** | **Status** |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------- | ---------- |
|
||||
| **Multi-Format Support** | Support EPUB, MOBI, KF8 (AZW3), FB2, CBZ, PDF (experimental) | ✅ |
|
||||
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
|
||||
| **Full-Text Search** | Search across the entire book to find relevant sections. | ✅ |
|
||||
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience. | ✅ |
|
||||
| **Excerpt Text for Note-Taking** | Easily excerpt text from books for detailed notes and analysis. | ✅ |
|
||||
| **Dictionary/Wikipedia Lookup** | Instantly look up words and terms when reading. | ✅ |
|
||||
| **Translate with DeepL** | Translate selected text instantly using DeepL for accurate translations. | ✅ |
|
||||
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
|
||||
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
|
||||
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
|
||||
| **Sync across Platforms** | Synchronize reading progress, notes, and bookmarks across all supported platforms. | ✅ |
|
||||
|
||||
## Planned Features
|
||||
|
||||
<div align="left">🛠 Building</div>
|
||||
<div align="left">🔄 Planned</div>
|
||||
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **Feature** | **Description** | **Priority** |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------ |
|
||||
| **Support iOS and Android** | Expand the APP to work on iOS and Android devices. | 🛠 |
|
||||
| **Text-to-Speech (TTS) Support** | Enable text-to-speech functionality for a more accessible reading experience. | 🛠 |
|
||||
| **Sync with Koreader** | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | 🔄 |
|
||||
| **Keyboard Navigation** | Implement vimium-style keybindings for book navigation. | 🔄 |
|
||||
| **Library Management** | Organize, sort, and manage your entire ebook library. | 🔄 |
|
||||
| **Support OPDS/Calibre** | Integrate OPDS/Calibre to access online libraries and catalogs. | 🔄 |
|
||||
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | 🔄 |
|
||||
| **Handwriting Annotations** | Add support for handwriting annotations using a pen on compatible devices. | 🔄 |
|
||||
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🔄 |
|
||||
| **In-Library Full-Text Search** | Search across your entire ebook library to find topics and quotes. | 🔄 |
|
||||
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🔄 |
|
||||
|
||||
Stay tuned for continuous improvements and updates! Contributions and suggestions are always welcome—let's build the ultimate reading experience together. 😊
|
||||
|
||||
@@ -84,35 +78,24 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Downloads
|
||||
|
||||
### Mobile Apps
|
||||
The Readest app is available for download! 🥳 🚀
|
||||
|
||||
<div align="center">
|
||||
<a href="https://apps.apple.com/app/id6738622779">
|
||||
<img alt="Download on the App Store" src="https://developer.apple.com/assets/elements/badges/download-on-the-app-store.svg" style="height: 50px;" /></a>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.bilingify.readest">
|
||||
<img alt="Get it on Google Play" src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg" style="height: 50px;" /></a>
|
||||
</div>
|
||||
|
||||
### Platform-Specific Downloads
|
||||
|
||||
- macOS / iOS / iPadOS : Search and install **Readest** on the [App Store][link-appstore], _also_ available on TestFlight for beta test (send your Apple ID to <readestapp@gmail.com> to request access).
|
||||
- Windows / Linux / Android: Visit and download **Readest** at [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Linux users can also install [Readest on Flathub][link-flathub].
|
||||
- Web: Visit and use **Readest for Web** at [https://web.readest.com][link-web-readest].
|
||||
- macOS : Search for "Readest" on the [macOS App Store][link-macos-appstore].
|
||||
- Windows / Linux: Visit [https://readest.com][link-website] or the [Releases on GitHub][link-gh-releases].
|
||||
- Web: Visit [https://web.readest.com][link-web-readest].
|
||||
- iOS / Android: coming soon 👀
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -122,8 +105,8 @@ Stay tuned for continuous improvements and updates! Contributions and suggestion
|
||||
For the best experience to build Readest for yourself, use a recent version of Node.js and Rust. Refer to the [Tauri documentation](https://v2.tauri.app/start/prerequisites/) for details on setting up the development environment prerequisites on different platforms.
|
||||
|
||||
```bash
|
||||
nvm install v24
|
||||
nvm use v24
|
||||
nvm install v22
|
||||
nvm use v22
|
||||
npm install -g pnpm
|
||||
rustup update
|
||||
```
|
||||
@@ -135,18 +118,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,169 +147,35 @@ For Windows targets, “Build Tools for Visual Studio 2022” (or a higher editi
|
||||
### 4. Build for Development
|
||||
|
||||
```bash
|
||||
# Start development for the Tauri app
|
||||
pnpm tauri dev
|
||||
# or start development for the Web app
|
||||
pnpm dev-web
|
||||
# preview with OpenNext build for the Web app
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
For Android:
|
||||
|
||||
```bash
|
||||
# Initialize the Android environment (run once)
|
||||
rm apps/readest-app/src-tauri/gen/android
|
||||
pnpm tauri android init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
git checkout apps/readest-app/src-tauri/gen/android
|
||||
|
||||
pnpm tauri android dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri android dev --host
|
||||
```
|
||||
|
||||
For iOS:
|
||||
|
||||
```bash
|
||||
# Set up the iOS environment (run once)
|
||||
pnpm tauri ios init
|
||||
pnpm tauri icon ../../data/icons/readest-book.png
|
||||
|
||||
pnpm tauri ios dev
|
||||
# or if you want to dev on a real device
|
||||
pnpm tauri ios dev --host
|
||||
```
|
||||
|
||||
### 5. Build for Production
|
||||
|
||||
```bash
|
||||
pnpm tauri build
|
||||
pnpm tauri android build
|
||||
pnpm tauri ios build
|
||||
```
|
||||
|
||||
Please refer to our release script if you experience any issues:
|
||||
https://github.com/readest/readest/blob/main/.github/workflows/release.yml
|
||||
|
||||
### 6. Setup dev environment with Nix
|
||||
|
||||
If you have Nix installed, you can leverage flake to enter a development shell
|
||||
with all the necessary dependencies:
|
||||
|
||||
```bash
|
||||
nix develop ./ops # enter a dev shell for the web app
|
||||
nix develop ./ops#ios # enter a dev shell for the ios app
|
||||
nix develop ./ops#android # enter a dev shell for the android app
|
||||
```
|
||||
|
||||
### 7. More information
|
||||
|
||||
Please check the [wiki][link-gh-wiki] of this project for more information on development.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 1. Readest Won’t Launch on Windows (Missing Edge WebView2 Runtime)
|
||||
|
||||
**Symptom**
|
||||
|
||||
- When you double-click readest.exe, nothing happens. No window appears, and Task Manager does not show the process.
|
||||
- This can affect both the standard installer and the portable version.
|
||||
|
||||
**Cause**
|
||||
|
||||
- Microsoft Edge WebView2 Runtime is either missing, outdated, or improperly installed on your system. Readest depends on WebView2 to render the interface on Windows.
|
||||
|
||||
**How to Fix**
|
||||
|
||||
1. Check if WebView2 is installed
|
||||
- Open “Add or Remove Programs” (a.k.a. Apps & features) on Windows. Look for “Microsoft Edge WebView2 Runtime.”
|
||||
2. Install or Update WebView2
|
||||
- Download the WebView2 Runtime directly from Microsoft: [link](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH).
|
||||
- If you prefer an offline installer, download the offline package and run it as an Administrator.
|
||||
3. Re-run Readest
|
||||
- After installing/updating WebView2, launch readest.exe again.
|
||||
- If you still encounter problems, reboot your PC and try again.
|
||||
|
||||
**Additional Tips**
|
||||
|
||||
- If reinstalling once doesn’t work, uninstall Edge WebView2 completely, then reinstall it with Administrator privileges.
|
||||
- Verify your Windows installation has the latest updates from Microsoft.
|
||||
|
||||
**Still Stuck?**
|
||||
|
||||
- See Issue [readest/readest#358](https://github.com/readest/readest/issues/358) for further details, or head over to our [Discord][link-discord] server and open a support discussion with detailed logs of your environment and the steps you’ve taken.
|
||||
|
||||
### 2. AppImage Launches but Only Shows a Taskbar Icon
|
||||
|
||||
On some Arch Linux systems—especially those using Wayland—the Readest AppImage may briefly show an icon in the taskbar and then exit without opening a window.
|
||||
|
||||
You might see logs such as:
|
||||
|
||||
```
|
||||
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
|
||||
```
|
||||
|
||||
This behavior is usually caused by compatibility issues between the bundled AppImage libraries and the system’s EGL / Wayland environment.
|
||||
|
||||
**Workaround 1: Launch with LD_PRELOAD (recommended)**
|
||||
|
||||
You can preload the system Wayland client library before launching the AppImage:
|
||||
|
||||
```
|
||||
LD_PRELOAD=/usr/lib/libwayland-client.so /path/to/Readest.AppImage
|
||||
```
|
||||
|
||||
This workaround has been confirmed to resolve the issue on affected systems.
|
||||
|
||||
**Workaround 2: Use the Flatpak Version**
|
||||
|
||||
If you prefer a more reliable out-of-the-box experience on Arch Linux, consider using the [Flatpak build on Flathub][link-flathub] instead. The Flatpak runtime helps avoid system library mismatches and tends to behave more consistently across different Wayland and X11 setups.
|
||||
|
||||
## Contributors
|
||||
|
||||
Readest is open-source, and contributions are welcome! Feel free to open issues, suggest features, or submit pull requests. Please **review our [contributing guidelines](CONTRIBUTING.md) before you start**. We also welcome you to join our [Discord][link-discord] community for either support or contributing guidance.
|
||||
|
||||
<a href="https://github.com/readest/readest/graphs/contributors">
|
||||
<a href="https://github.com/chrox/readest/graphs/contributors">
|
||||
<p align="left">
|
||||
<img width="500" src="https://contrib.rocks/image?repo=readest/readest" alt="A table of avatars from the project's contributors" />
|
||||
<img width="100" src="https://contrib.rocks/image?repo=chrox/readest" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
|
||||
## Support
|
||||
|
||||
If Readest has been useful to you, consider supporting its development. You can [become a sponsor on GitHub](https://github.com/sponsors/readest), [donate via Stripe](https://donate.stripe.com/4gMcN5aZdcE52kW3TFgjC01), or [donate with crypto](https://donate.readest.com). Your contribution helps us squash bugs faster, improve performance, and keep building great features.
|
||||
|
||||
### Sponsors
|
||||
|
||||
<p align="center">
|
||||
<a title="Browser testing via TestMu AI" href="https://www.testmuai.com/?utm_medium=sponsor&utm_source=readest" target="_blank">
|
||||
<img src="https://raw.githubusercontent.com/readest/readest/refs/heads/main/data/sponsors/testmu-ai-logo.png" style="vertical-align: middle;" width="250" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
Readest is free software: you can redistribute it and/or modify it under the terms of the [GNU Affero General Public License](https://www.gnu.org/licenses/agpl-3.0.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
The following libraries and frameworks are used in this software:
|
||||
The following JavaScript libraries are bundled in this software:
|
||||
|
||||
- [foliate-js](https://github.com/johnfactotum/foliate-js), which is MIT licensed.
|
||||
- [zip.js](https://github.com/gildas-lormeau/zip.js), which is licensed under the BSD-3-Clause license.
|
||||
- [fflate](https://github.com/101arrowz/fflate), which is MIT licensed.
|
||||
- [PDF.js](https://github.com/mozilla/pdf.js), which is licensed under Apache License 2.0.
|
||||
- [daisyUI](https://github.com/saadeghi/daisyui), which is MIT licensed.
|
||||
- [marked](https://github.com/markedjs/marked), which is MIT licensed.
|
||||
- [next.js](https://github.com/vercel/next.js), which is MIT licensed.
|
||||
- [react-icons](https://github.com/react-icons/react-icons), which has various open-source licenses.
|
||||
- [react](https://github.com/facebook/react), which is MIT licensed.
|
||||
- [tauri](https://github.com/tauri-apps/tauri), which is MIT licensed.
|
||||
|
||||
The following fonts are utilized in this software, either bundled within the application or provided through web fonts:
|
||||
|
||||
[Bitter](https://fonts.google.com/specimen/Bitter), [Fira Code](https://fonts.google.com/specimen/Fira+Code), [Inter](https://fonts.google.com/specimen/Inter), [Literata](https://fonts.google.com/specimen/Literata), [Merriweather](https://fonts.google.com/specimen/Merriweather), [Noto Sans](https://fonts.google.com/specimen/Noto+Sans), [Roboto](https://fonts.google.com/specimen/Roboto), [LXGW WenKai](https://github.com/lxgw/LxgwWenKai), [MiSans](https://hyperos.mi.com/font/en/), [Source Han](https://github.com/adobe-fonts/source-han-sans/), [WenQuanYi Micro Hei](http://wenq.org/wqy2/)
|
||||
|
||||
We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.netlify.app) for offering open-source tools that enable the use of Chinese fonts on the web.
|
||||
|
||||
---
|
||||
|
||||
@@ -334,31 +183,18 @@ We would also like to thank the [Web Chinese Fonts Plan](https://chinese-font.ne
|
||||
|
||||
[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-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/gntyVNk3BJ
|
||||
[link-parallel-read]: https://readest.com/#parallel-read
|
||||
[link-koreader]: https://github.com/koreader/koreader
|
||||
[link-hellogithub]: https://hellogithub.com/repository/8a5b6ade2aee461a8bd94e59200682a7
|
||||
[link-deepwiki]: https://deepwiki.com/readest/readest
|
||||
[link-locales]: https://github.com/readest/readest/tree/main/apps/readest-app/public/locales
|
||||
[link-kosync-wiki]: https://github.com/readest/readest/wiki/Sync-with-Koreader-devices
|
||||
[link-reddit]: https://reddit.com/r/readest/
|
||||
|
||||
-142
@@ -1,142 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Threat Model
|
||||
|
||||
### Overview
|
||||
|
||||
Readest is a cross-platform e-reader (macOS, Windows, Linux, Android, iOS, Web) built on Next.js and Tauri. It processes user-supplied ebook files, syncs data to the cloud, integrates with external services (OPDS catalogs, KOReader, DeepL, Yandex), and handles user authentication.
|
||||
|
||||
### Assets
|
||||
|
||||
| Asset | Description |
|
||||
| ------------------------------ | ------------------------------------------------------------------------------------ |
|
||||
| Ebook files | User-uploaded EPUB, MOBI, PDF, and other formats stored locally and in cloud storage |
|
||||
| Reading progress & annotations | Highlights, bookmarks, and notes synced across devices |
|
||||
| User credentials | Authentication tokens and session data for cloud sync |
|
||||
| User preferences & settings | Reading preferences, custom fonts, theme configurations |
|
||||
| External API keys | Translation service credentials (DeepL, Yandex) configured by users |
|
||||
|
||||
### Threat Actors
|
||||
|
||||
| Actor | Motivation |
|
||||
| ----------------------- | ---------------------------------------------------------- |
|
||||
| Malicious ebook author | Craft a malformed file to exploit the parser or renderer |
|
||||
| Network attacker (MitM) | Intercept sync traffic to steal credentials or inject data |
|
||||
| Malicious OPDS server | Serve crafted catalog responses to exploit the client |
|
||||
| Compromised dependency | Supply chain attack via npm or Cargo ecosystem |
|
||||
| Unauthorized user | Access another user's synced library or annotations |
|
||||
|
||||
### Attack Surfaces & Mitigations
|
||||
|
||||
#### 1. Ebook File Parsing
|
||||
|
||||
- **Risk:** Malformed EPUB/MOBI/PDF files could trigger parser bugs, path traversal, or script injection via embedded HTML/JS.
|
||||
- **Mitigations:** Ebook content is rendered in a sandboxed iframe. External script execution is blocked. File parsing is isolated from the main process.
|
||||
|
||||
#### 2. Cloud Sync & Authentication
|
||||
|
||||
- **Risk:** Credential theft, session hijacking, or unauthorized access to another user's library data.
|
||||
- **Mitigations:** All sync traffic uses HTTPS/TLS. Authentication tokens are stored securely (OS keychain/secure storage). Server-side authorization ensures users can only access their own data.
|
||||
|
||||
#### 3. OPDS / External Catalog Integration
|
||||
|
||||
- **Risk:** A malicious OPDS server could serve crafted XML to exploit the parser, or redirect downloads to malicious files.
|
||||
- **Mitigations:** OPDS responses are parsed defensively. Users explicitly add catalog sources. Downloaded files are treated as untrusted user content.
|
||||
|
||||
#### 4. Rendered HTML/JS in Ebook Content
|
||||
|
||||
- **Risk:** Embedded JavaScript in EPUB files could attempt XSS or data exfiltration.
|
||||
- **Mitigations:** Book content is rendered in a sandboxed iframe with scripting restrictions. Navigation outside the book context is blocked.
|
||||
|
||||
#### 5. Supply Chain
|
||||
|
||||
- **Risk:** Compromised npm or Cargo packages could introduce malicious code.
|
||||
- **Mitigations:** Dependencies are pinned via `pnpm-lock.yaml` and `Cargo.lock`. Dependabot and GitHub's dependency review are enabled for automated vulnerability detection.
|
||||
|
||||
#### 6. Desktop Native Code (Tauri)
|
||||
|
||||
- **Risk:** Tauri IPC commands could be abused by malicious web content to access the filesystem or OS APIs.
|
||||
- **Mitigations:** Tauri's allowlist restricts which IPC commands are exposed. File system access is scoped to the application data directory.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Vulnerabilities in user's operating system or browser outside of Readest's control
|
||||
- Physical access attacks to a user's device
|
||||
- Issues in third-party services (DeepL, Yandex, Calibre) themselves
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Readest does not currently maintain separate release channels. Security updates are provided only for the latest release series.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.10.x | :white_check_mark: |
|
||||
| < 0.10 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report suspected vulnerabilities privately. Do not open a public GitHub
|
||||
issue or discussion for security-sensitive reports.
|
||||
|
||||
Use GitHub's private vulnerability reporting for this repository:
|
||||
|
||||
<https://github.com/readest/readest/security/advisories/new>
|
||||
|
||||
When submitting a report, include:
|
||||
|
||||
- A clear description of the issue and the affected component
|
||||
- Steps to reproduce, proof of concept, or a minimal test case
|
||||
- The versions, platforms, or environments you tested
|
||||
- Any suggested remediation or mitigating details, if available
|
||||
|
||||
What to expect after you report:
|
||||
|
||||
- We will aim to acknowledge receipt within 3 business days.
|
||||
- We may contact you for additional details, reproduction steps, or validation.
|
||||
- If the report is accepted, we will work on a fix and coordinate disclosure.
|
||||
- If the report is declined, we will explain why, for example if the behavior is
|
||||
expected, unsupported, or not reproducible.
|
||||
|
||||
Please keep vulnerability details private until a fix is available and the
|
||||
maintainers have approved disclosure.
|
||||
|
||||
## Incident Response Plan
|
||||
|
||||
When a security vulnerability is confirmed, we follow this process:
|
||||
|
||||
### 1. Triage (Day 1–2)
|
||||
|
||||
- Assign a severity level (Critical / High / Medium / Low) based on impact and exploitability.
|
||||
- Identify affected versions, components, and users.
|
||||
- Assign an owner responsible for coordinating the response.
|
||||
|
||||
### 2. Containment (Day 1–3)
|
||||
|
||||
- Assess whether an immediate mitigation or workaround can be published.
|
||||
- Limit further exposure where possible (e.g., disable affected features, update dependencies).
|
||||
|
||||
### 3. Remediation (Day 3–14, depending on severity)
|
||||
|
||||
- Develop and internally review a fix.
|
||||
- Validate the fix does not introduce regressions.
|
||||
- Prepare a patched release and update changelog.
|
||||
|
||||
### 4. Disclosure & Release
|
||||
|
||||
- Coordinate disclosure timing with the reporter.
|
||||
- Publish a GitHub Security Advisory with CVE if applicable.
|
||||
- Release the patched version and notify users via release notes.
|
||||
|
||||
### 5. Post-Incident Review
|
||||
|
||||
- Document the root cause, timeline, and resolution.
|
||||
- Update processes or controls to prevent recurrence.
|
||||
|
||||
### Severity Definitions
|
||||
|
||||
| Severity | Description |
|
||||
| -------- | --------------------------------------------------------------------- |
|
||||
| Critical | Remote code execution, full data compromise, or authentication bypass |
|
||||
| High | Significant data exposure, privilege escalation, or denial of service |
|
||||
| Medium | Limited data exposure or functionality disruption |
|
||||
| Low | Minor issues with minimal security impact |
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/memory
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/plans
|
||||
@@ -1 +0,0 @@
|
||||
../.claude/rules
|
||||
@@ -1,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.
|
||||
@@ -2,11 +2,5 @@ PDFJS_BUILD_PATH=../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build
|
||||
PDFJS_FONTS_PATH=../../packages/foliate-js/node_modules/pdfjs-dist
|
||||
PDFJS_STYLE_PATH=../../packages/foliate-js/vendor/pdfjs
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_URL_BASE64="aHR0cHM6Ly91cy5pLnBvc3Rob2cuY29t"
|
||||
NEXT_PUBLIC_DEFAULT_POSTHOG_KEY_BASE64="cGhjX0x3ekhZRWtsZUVub3ZSc05ZQlRpTVRTV2MyS1NUOFdZMzBIWWFhN0ZPa1IK"
|
||||
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_URL_BASE64="aHR0cHM6Ly9yZWFkZXN0LnN1cGFiYXNlLmNv"
|
||||
NEXT_PUBLIC_DEFAULT_SUPABASE_KEY_BASE64="ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKemRYQmhZbUZ6WlNJc0luSmxaaUk2SW5aaWMzbDRablZ6YW1weFpIaHJhbkZzZVhOaklpd2ljbTlzWlNJNkltRnViMjRpTENKcFlYUWlPakUzTXpReE1qTTJOekVzSW1WNGNDSTZNakEwT1RZNU9UWTNNWDAuM1U1VXFhb3VfMVNnclZlMWVvOXJBcGMwdUtqcWhwUWRVWGh2d1VIbVVmZw=="
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_DEV_BASE64="cGtfdGVzdF81MVJmQmdLRTdSWW5pTWsxc0tDV2RUd2hMZzcySzk4eDRWcjlIdDdsRFBONngzcnpZYmhydGtNQnpDdzZKbHFaRVVITVp5eVNjVXhCZXVkcGppWTk0WXNHcDAweFlRRnRRaUU="
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY_BASE64="cGtfbGl2ZV81MVFYN3dRRU5ndjJFOUxQRHpZUlE5TlJJeTNjd09EZ1AzSkNFRHRPWlFtdFJWc3Brd053ZE1NNUpIVnVPTmJWcjZ3VGFCMUNZR1pJMmRPVWppTkY0bHJvVjAwalE4TkpkdWk="
|
||||
NEXT_PUBLIC_DEV_SUPABASE_URL=https://gxkhxxxeapexynpouyjz.supabase.co
|
||||
NEXT_PUBLIC_DEV_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imd4a2h4eHhlYXBleHlucG91eWp6Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ0MzAwNTksImV4cCI6MjA1MDAwNjA1OX0.jhinkQsimQoidsg_U59YD5ROw4PmMJQNKuyXbr4TiQA
|
||||
@@ -1,32 +0,0 @@
|
||||
NEXT_PUBLIC_POSTHOG_KEY=YOUR_POSTHOG_KEY
|
||||
NEXT_PUBLIC_POSTHOG_HOST=YOUR_POSTHOG_HOST
|
||||
|
||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
|
||||
NEXT_PUBLIC_STORAGE_FIXED_QUOTA=1073741824
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=https://your-api-base-url.com
|
||||
|
||||
SUPABASE_ADMIN_KEY=YOUR_SUPABASE_ADMIN_KEY
|
||||
|
||||
DEEPL_PRO_API_KEYS=YOUR_DEEPL_PRO_API_KEYS
|
||||
DEEPL_FREE_API_KEYS=YOUR_DEEPL_FREE_API_KEYS
|
||||
|
||||
# r2, s3
|
||||
NEXT_PUBLIC_OBJECT_STORAGE_TYPE=r2
|
||||
|
||||
R2_TOKEN_VALUE=YOUR_R2_TOKEN_VALUE
|
||||
R2_ACCESS_KEY_ID=YOUR_R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY=YOUR_R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET_NAME=YOUR_R2_BUCKET_NAME
|
||||
R2_ACCOUNT_ID=YOUR_R2_ACCOUNT_ID
|
||||
R2_REGION=YOUR_R2_REGION
|
||||
|
||||
S3_ENDPOINT=PLACE_HOLDER
|
||||
S3_ACCESS_KEY_ID=PLACE_HOLDER
|
||||
S3_SECRET_ACCESS_KEY=PLACE_HOLDER
|
||||
S3_BUCKET_NAME=PLACE_HOLDER
|
||||
S3_REGION=PLACE_HOLDER
|
||||
|
||||
TEMP_STORAGE_PUBLIC_BUCKET_NAME=PLACE_HOLDER
|
||||
@@ -1,3 +1 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
|
||||
DBUS_ID=com.bilingify.readest
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=tauri
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
@@ -1,3 +0,0 @@
|
||||
NEXT_PUBLIC_APP_PLATFORM=web
|
||||
AI_GATEWAY_API_KEY=your_key_here
|
||||
NEXT_PUBLIC_AI_GATEWAY_API_KEY=your_key_here
|
||||
@@ -8,11 +8,6 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
.test-sandbox-node/
|
||||
.vitest-attachments/
|
||||
|
||||
# benchmarks — personal local perf history; share via PR/issue copy-paste
|
||||
bench/results.jsonl
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
@@ -37,7 +32,7 @@ yarn-error.log*
|
||||
/private_keys
|
||||
|
||||
# plists
|
||||
Entitlements*.plist
|
||||
*.plist
|
||||
|
||||
# local confs
|
||||
tauri.*.conf.json
|
||||
@@ -45,38 +40,12 @@ tauri.*.conf.json
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# open-next
|
||||
.open-next
|
||||
.wrangler
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
#generated
|
||||
src-tauri/gen
|
||||
|
||||
# vendor
|
||||
/public/vendor
|
||||
|
||||
# Auto Generated PWA files
|
||||
/public/sw.js
|
||||
/public/workbox-*.js
|
||||
/public/fallback-*.js
|
||||
/public/swe-worker-*.js
|
||||
|
||||
/dist/
|
||||
|
||||
.context/
|
||||
.claude/settings.local.json
|
||||
.claude/skills
|
||||
|
||||
# Playwright web e2e
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
## Project Overview
|
||||
|
||||
Readest is a cross-platform ebook reader built as a **Next.js 16 + Tauri v2** hybrid app. It's part of a pnpm monorepo at `/apps/readest-app/`. The app runs on web (CloudFlare Workers), desktop (macOS/Windows/Linux via Tauri), and mobile (iOS/Android via Tauri).
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev-web # Web-only dev server (no Rust compilation needed)
|
||||
pnpm tauri dev # Desktop dev with Tauri (compiles Rust backend)
|
||||
|
||||
# Building
|
||||
pnpm build # Build Next.js for Tauri
|
||||
pnpm build-web # Build Next.js for web deployment
|
||||
|
||||
# Testing (see [docs/testing.md](docs/testing.md) for full details)
|
||||
pnpm test # Unit tests (vitest + jsdom)
|
||||
pnpm test -- src/__tests__/utils/misc.test.ts # Run a single test file
|
||||
pnpm test -- --watch # Watch mode
|
||||
pnpm test:browser # Browser tests (Chromium via Playwright)
|
||||
pnpm tauri:dev:test # Start Tauri app with webdriver
|
||||
pnpm test:tauri # Run Tauri integration tests
|
||||
|
||||
# Linting & Formatting
|
||||
pnpm lint # Biome (linter) + tsgo (type check)
|
||||
pnpm format # Biome formatter (runs from monorepo root)
|
||||
pnpm format:check # Check formatting without writing (Biome)
|
||||
|
||||
# Rust
|
||||
pnpm fmt:check # Check formatting Rust code (src-tauri)
|
||||
pnpm clippy:check # Lint Rust code (src-tauri)
|
||||
```
|
||||
|
||||
### Source Layout
|
||||
|
||||
| Directory | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `src/app/` | Next.js App Router pages and API routes |
|
||||
| `src/components/` | React components (reader, settings, library, assistant, etc.) |
|
||||
| `src/services/` | Business logic: TTS, translators, OPDS, sync, AI, metadata |
|
||||
| `src/store/` | Zustand state stores |
|
||||
| `src/hooks/` | Custom React hooks |
|
||||
| `src/libs/` | Document loaders, payment, storage, sync |
|
||||
| `src/utils/` | Pure utility functions |
|
||||
| `src/types/` | TypeScript type definitions |
|
||||
| `src/context/` | React Context providers (Auth, Env, Sync, etc.) |
|
||||
| `src/workers/` | Web Workers for background tasks |
|
||||
| `src-tauri/` | Rust backend: Tauri plugins, platform-specific code |
|
||||
|
||||
### Path Aliases (tsconfig)
|
||||
|
||||
- `@/*` → `./src/*`
|
||||
- `@/components/ui/*` → `./src/components/primitives/*`
|
||||
|
||||
### Rust Backend (`src-tauri/`)
|
||||
|
||||
Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Custom Tauri plugins are in `src-tauri/plugins/`.
|
||||
|
||||
## Git Worktrees
|
||||
|
||||
Always use `pnpm worktree:new <branch-name|pr-number>` to create worktrees. Never use `git worktree add` directly — the script handles submodule initialization (simplecc WASM, foliate-js), dependency installation, `.env` copying, vendor assets, and Tauri gen symlinks that are required for lint and tests to pass.
|
||||
|
||||
```bash
|
||||
pnpm worktree:new feat/my-feature # New branch from origin/main
|
||||
pnpm worktree:new 3837 # Checkout PR #3837 with push access to fork
|
||||
```
|
||||
|
||||
## Agent Workspace
|
||||
|
||||
Project-related agent context lives under `.agents/`, which is a symlink to `.claude/`. Treat `.agents/` as the canonical path when looking for or updating local agent material:
|
||||
|
||||
- `.agents/memory/` — persistent project memory and recurring context
|
||||
- `.agents/plans/` — active or archived implementation plans
|
||||
- `.agents/rules/` — project rules for test-first work, TypeScript, verification, and related workflows
|
||||
|
||||
## Project Rules
|
||||
|
||||
Rules are in `.agents/rules/`: test-first, typescript, verification.
|
||||
|
||||
### Implementation Scope
|
||||
|
||||
For every coding task, write the minimum code that solves the requested problem.
|
||||
|
||||
- Do not add features beyond what was asked.
|
||||
- Do not add abstractions for single-use code.
|
||||
- Do not add flexibility or configurability unless requested.
|
||||
- Do not add error handling for impossible scenarios.
|
||||
- If a solution is much longer than necessary, simplify it before finishing.
|
||||
- Before shipping, ask: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
### i18n
|
||||
|
||||
See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `stubTranslation` usage in non-React modules, and extraction workflow.
|
||||
|
||||
### Safe Area Insets
|
||||
|
||||
See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges.
|
||||
|
||||
### Design System
|
||||
|
||||
UI/UX rules — surface tiers, action vocabulary, settings primitives (`BoxedList`, `SettingsRow`, `SettingsSwitchRow`, `SettingsSelect`, `NavigationRow`, `Tips`, etc.), boxed-list anatomy, RTL conventions, e-ink overlay, and anti-patterns — live in [DESIGN.md](DESIGN.md). Codify recurring decisions there so they persist for the team and future contributors. Reach for the primitives in `src/components/settings/primitives/` instead of inlining chassis classes.
|
||||
|
||||
### E-ink mode
|
||||
|
||||
Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones:
|
||||
|
||||
- **Surfaces / inputs** — add `eink-bordered`. In eink mode it swaps to `bg-base-100` + 1px `base-content` border. Use it on inputs, custom button backgrounds, ghost-styled cancel buttons, and any container that needs a visible boundary.
|
||||
- **Primary action buttons** — add `btn-primary` (alongside whatever Tailwind classes you use for color themes). The `[data-eink] .btn-primary` rule inverts to `base-content` bg + `base-100` text so the primary CTA stays distinct from secondary actions.
|
||||
- **`.modal-box`** picks up no-shadow + 1px border automatically; dialogs that use it don't need additions.
|
||||
- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference.
|
||||
|
||||
When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible.
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -1,972 +0,0 @@
|
||||
## Readest Design Language
|
||||
|
||||
Readest's UI is **Adwaita-aligned**, **e-ink-first**, **cross-platform-aware**. This doc is the
|
||||
reference for that language: principles, vocabulary, anti-patterns. New work should read it
|
||||
before reaching for daisyui defaults; existing work is gradually migrating toward it.
|
||||
|
||||
### Status
|
||||
|
||||
This doc is the **first articulation** of the system, not a retrospective. Many existing
|
||||
components don't fully match it yet (especially older buttons and ad-hoc panels). The goal
|
||||
is that **new code uses these conventions** and **migrations land opportunistically** as
|
||||
features get touched.
|
||||
|
||||
---
|
||||
|
||||
### 1. Identity & lineage
|
||||
|
||||
Readest's visual language descends from **Adwaita / libadwaita** — GNOME's design system —
|
||||
adapted for a cross-platform Tauri + Next.js app that also runs on iOS, Android, web, and
|
||||
e-ink readers.
|
||||
|
||||
What we take from Adwaita:
|
||||
|
||||
- **Content first, chrome recedes.** The reading surface is the product. Settings, toolbars,
|
||||
popups never compete with the page.
|
||||
- **Boldly minimal.** Restraint over density. Whitespace is structural.
|
||||
- **Surface hierarchy** — window → view → card — three explicit elevation tiers, no shadow
|
||||
gymnastics.
|
||||
- **Color discipline.** Brand color is rare and earned. Neutral palette carries the weight.
|
||||
- **Boxed lists are the chassis.** AdwActionRow's prefix · title · suffix anatomy is the
|
||||
canonical settings/list row everywhere.
|
||||
- **Pills, ghosts, flats.** Three-tier button palette: pill/circular ghost in headers, flat
|
||||
secondary over view-bg, accent CTA only when truly primary.
|
||||
- **Banner vs Toast.** AdwBanner = inline, top-of-window, persistent. AdwToast = transient,
|
||||
bottom slide-in.
|
||||
- **Switches over checkboxes** for boolean settings.
|
||||
- **Subtle motion.** Short, ease-out, never bouncy.
|
||||
|
||||
What's Readest-specific:
|
||||
|
||||
- **E-ink as a first-class mode.** Every surface flips to flat 1px contrast borders under
|
||||
`[data-eink='true']`. Adwaita is desktop-GNOME-only; we ship to e-ink readers and the
|
||||
visual language has to survive there.
|
||||
- **Cross-platform reality.** Readest runs on macOS, Windows, Linux, iOS, Android, web. The
|
||||
identity stays Adwaita; platform grace notes (radii, target sizes) follow host
|
||||
conventions where they matter.
|
||||
|
||||
---
|
||||
|
||||
### 2. Principles
|
||||
|
||||
The seven rules. When in doubt, work backward from these.
|
||||
|
||||
#### 2.1 Surfaces continue surfaces
|
||||
|
||||
A control that extends a list/card should match its parent's border + fill. The
|
||||
"+ Import Dictionary" button at `src/components/settings/CustomDictionaries.tsx` reads as
|
||||
detached card siblings of the dictionary list above it because they share
|
||||
`border-base-200 bg-base-100 rounded-lg`.
|
||||
|
||||
> **Bad**: a list of dictionaries in a `bg-base-100` card, followed by a `btn-outline btn-primary`
|
||||
> add button. The button shouts; the list whispers; the eye bounces.
|
||||
>
|
||||
> **Good**: list and add-button share the same surface vocabulary. The eye flows.
|
||||
|
||||
#### 2.2 Color is earned
|
||||
|
||||
Brand `primary` is reserved for **the** primary action of a surface. Most actions don't have
|
||||
a primary action — they have a list of equally-weighted choices, or a single accent.
|
||||
|
||||
- Settings dialog has no primary. Every panel is a list of toggles. **Zero brand color.**
|
||||
- "Import a Book" in onboarding is a primary CTA. **One brand color.**
|
||||
- "Add Web Search" extends a list — it's not the surface's primary action. **Neutral.**
|
||||
|
||||
#### 2.3 Two-step depth
|
||||
|
||||
State changes cycle through **`base-100 → base-200 → base-300`** instead of recoloring.
|
||||
Hover lifts, active deepens, disabled fades opacity. This is theme-safe (works across all
|
||||
11 color themes), e-ink-friendly (depth is preserved as borders, not shades), and
|
||||
calmer than recoloring.
|
||||
|
||||
#### 2.4 Localize the hover signal
|
||||
|
||||
When a button hovers, **one focal element changes**, not the whole button. The icon chip
|
||||
inverts; the label stays steady. The badge intensifies; the row stays neutral. This reads
|
||||
as deliberate, not decorative.
|
||||
|
||||
#### 2.5 Motion is color, not transform
|
||||
|
||||
Default to `transition-colors duration-150`. No `scale`, no `translate`, no `rotate` unless
|
||||
the motion **is** the message (a chevron rotating to indicate expansion is fine; a button
|
||||
that scales on hover is not). Transforms break under `[data-eink='true']` and feel
|
||||
gimmicky under Adwaita's calm rhythm.
|
||||
|
||||
#### 2.6 Eink-first by default
|
||||
|
||||
Every custom-styled bordered surface gets the `eink-bordered` class. Every primary action
|
||||
gets `btn-primary` (which has dedicated eink rules). Don't rely on color or shadow alone
|
||||
for hierarchy — eink screens have neither.
|
||||
|
||||
If you can't toggle Settings → Misc → Eink and still tell which button is the CTA, the
|
||||
hierarchy is broken.
|
||||
|
||||
#### 2.7 Focus is visible but quiet
|
||||
|
||||
Keyboard focus needs a visible ring. `focus-visible:ring-2 focus-visible:ring-base-content/15`
|
||||
is the canonical treatment for custom buttons. Loud `ring-primary` reserved for inputs
|
||||
where the focus state IS the affordance.
|
||||
|
||||
#### 2.8 RTL: always use logical properties (REQUIRED)
|
||||
|
||||
Readest ships with RTL languages enabled. **Never use direction-bound Tailwind
|
||||
utilities** when a logical equivalent exists — the visual edges flip in RTL,
|
||||
the logical ones don't.
|
||||
|
||||
| Don't use | Use instead |
|
||||
| ---------------------------------- | ---------------------------------- |
|
||||
| `pl-*` / `pr-*` | `ps-*` (start) / `pe-*` (end) |
|
||||
| `ml-*` / `mr-*` | `ms-*` / `me-*` |
|
||||
| `text-left` / `text-right` | `text-start` / `text-end` |
|
||||
| `border-l` / `border-r` | `border-s` / `border-e` |
|
||||
| `rounded-l-*` / `rounded-r-*` | `rounded-s-*` / `rounded-e-*` |
|
||||
| `left-*` / `right-*` (positioning) | `start-*` / `end-*` |
|
||||
| `justify-start` / `justify-end` | (these ARE direction-aware) — keep |
|
||||
|
||||
The `flex-row` direction is automatically reversed in RTL by the browser, so
|
||||
you usually don't need to do anything for `flex` / `gap`. Only **explicit
|
||||
edges** (padding, margin, borders, radius, absolute positioning) need
|
||||
logical properties.
|
||||
|
||||
**Quick scan when reviewing a diff:** grep for `\b(pl|pr|ml|mr|left-|right-|text-left|text-right|border-l|border-r|rounded-l|rounded-r)-` in changed files. Any hit that isn't a deliberate LTR-only
|
||||
case (rare — usually only icon glyphs that have a fixed orientation) should
|
||||
be flipped to the logical equivalent.
|
||||
|
||||
#### 2.9 Every panel and sub-page starts with title + description (REQUIRED)
|
||||
|
||||
Every settings panel and every sub-page must open with:
|
||||
|
||||
1. **A title** — the panel name. Style: `text-lg font-semibold tracking-tight`. In a
|
||||
top-level panel this is an `<h2>`; in a sub-page this is the `parentLabel /
|
||||
currentLabel` breadcrumb in `SubPageHeader` (which uses the same typography so the
|
||||
word stays anchored visually as the user navigates in/out).
|
||||
2. **A one-line description** — a short sentence under the title explaining what this
|
||||
surface does or how it fits in the user's workflow. Style: `text-sm
|
||||
text-base-content/70 leading-relaxed`. Skip it only when the surface is so trivial
|
||||
the breadcrumb already says everything (rare — when in doubt, write one).
|
||||
|
||||
Why: orientation, visual rhythm, and Adwaita parity (`AdwPreferencesPage` always has
|
||||
both). The same vertical opening across every surface makes the system feel cohesive
|
||||
and gives users a predictable place to learn what a screen does.
|
||||
|
||||
**Canonical components.** The `<SubPageHeader>` primitive in
|
||||
`src/components/settings/SubPageHeader.tsx` accepts a `description?: React.ReactNode`
|
||||
prop that renders the description in the canonical style — sub-pages should pass it
|
||||
there rather than rolling their own `<p>` below the header. Top-level panels currently
|
||||
inline the title + description; if a third or fourth panel needs the same pattern,
|
||||
extract a `<PanelHeader>` primitive following the same shape.
|
||||
|
||||
**Examples.**
|
||||
|
||||
```tsx
|
||||
// Sub-page (Integrations → OPDS Catalogs)
|
||||
<SubPageHeader
|
||||
parentLabel={_('Integrations')}
|
||||
currentLabel={_('OPDS Catalogs')}
|
||||
description={_('Browse and download books from online catalogs')}
|
||||
onBack={() => setSubPage(null)}
|
||||
/>
|
||||
|
||||
// Top-level panel (Integrations panel root)
|
||||
<div className='w-full'>
|
||||
<h2 className='mb-1.5 text-lg font-semibold tracking-tight'>{_('Integrations')}</h2>
|
||||
<p className='text-base-content/70 text-sm leading-relaxed'>
|
||||
{_('Connect Readest to external services for sync, highlights, and catalogs.')}
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Surface hierarchy
|
||||
|
||||
Three named tiers, mapped onto daisyui tokens. Use these terms in conversation and code
|
||||
comments even though the classes are still daisyui-native.
|
||||
|
||||
| Tier | Token | Role | Example |
|
||||
| ---------- | ------------------------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| **Window** | `bg-base-200` | The outermost backdrop. Modal scrims, dialog content area, scroll containers. | `<Dialog>` body |
|
||||
| **View** | `bg-base-100/60` or `bg-base-200/40` | Mid-tier surface inside a window. Tip boxes, secondary panels. | The "提示 / Tips" callout in CustomDictionaries |
|
||||
| **Card** | `bg-base-100` | Top-tier content surface. Boxed lists, popovers, modal-box. | The dictionaries list card |
|
||||
|
||||
Border treatment:
|
||||
|
||||
- **Window** has no border (it IS the boundary).
|
||||
- **View** uses no border or `border-base-200/60` for very soft delineation.
|
||||
- **Card** uses `border border-base-200`. In e-ink, `eink-bordered` flips it to 1px
|
||||
`border-base-content`.
|
||||
|
||||
Corner radius:
|
||||
|
||||
- **Card / View**: `rounded-lg` (8px) — Readest's house radius. Adwaita uses 9px; 8px is
|
||||
close enough and matches Tailwind's scale.
|
||||
- **Modal / Sheet**: `modal-box` default (~1rem / 16px) — bigger surfaces get bigger radii.
|
||||
- **Pills / Chips**: `rounded-full`.
|
||||
- **Inputs / small buttons**: `rounded-md` (6px) or `rounded-lg` (8px).
|
||||
|
||||
#### Surface continuity rule
|
||||
|
||||
When a control extends a card (an "add row" affordance, a footer button bar attached to a
|
||||
list), it inherits the card's surface treatment: same `bg-base-100`, same
|
||||
`border-base-200`, same `rounded-lg`. It is the card grown by one row.
|
||||
|
||||
---
|
||||
|
||||
### 4. Action vocabulary
|
||||
|
||||
Six archetypes. Pick by **role**, not by **appearance**.
|
||||
|
||||
#### 4.1 Accent CTA
|
||||
|
||||
The primary, accent-colored button. **One per surface, max.** Submit on a form, "Open
|
||||
Book", "Sign In".
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-primary';
|
||||
```
|
||||
|
||||
Eink: `btn-primary` has dedicated rules (inverts to base-content bg + base-100 text) so it
|
||||
stays distinct from secondary actions on monochrome screens.
|
||||
|
||||
#### 4.2 Suggested
|
||||
|
||||
A non-accent-but-emphasized action. Used when there are multiple equally-weighted actions
|
||||
and one is the recommended path. Adwaita's "suggested-action" CSS class.
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-neutral';
|
||||
```
|
||||
|
||||
Rare. Most surfaces don't need this tier.
|
||||
|
||||
#### 4.3 Flat
|
||||
|
||||
The default secondary button. Sits on a view or card surface, no border, hover lifts to
|
||||
`base-200`. The bulk of buttons should be flat.
|
||||
|
||||
```tsx
|
||||
className="btn btn-ghost"
|
||||
// or for a custom surface treatment:
|
||||
className={clsx(
|
||||
'rounded-lg px-4 py-2 text-sm font-medium',
|
||||
'hover:bg-base-200 transition-colors duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15',
|
||||
)}
|
||||
```
|
||||
|
||||
#### 4.4 Pill / Circular ghost
|
||||
|
||||
Compact icon-only buttons in header bars and toolbars. Always `rounded-full`,
|
||||
`btn-circle` or hand-rolled circular ghost.
|
||||
|
||||
```tsx
|
||||
className = 'btn btn-ghost btn-circle h-8 min-h-8 w-8 p-0';
|
||||
```
|
||||
|
||||
The window controls in `SettingsDialog.tsx` (search, menu, close) use this archetype.
|
||||
|
||||
#### 4.5 Destructive
|
||||
|
||||
Delete, remove, irreversible. Adwaita uses `destructive-action`. Readest uses red
|
||||
sparingly — usually only the icon, not the whole button.
|
||||
|
||||
```tsx
|
||||
// Icon-only delete X in delete mode:
|
||||
className = 'btn btn-ghost btn-sm shrink-0 px-1';
|
||||
// with <IoMdCloseCircleOutline className="text-error h-4 w-4" />
|
||||
```
|
||||
|
||||
For destructive **dialogs** (confirmation modals), the confirm button can be `btn-error`,
|
||||
but only in the modal — never on the main surface.
|
||||
|
||||
#### 4.6 ListExtension
|
||||
|
||||
A Readest-named archetype for "add another row to the list above" affordances. The two
|
||||
buttons at the bottom of `CustomDictionaries.tsx` are the canonical example.
|
||||
|
||||
Anatomy:
|
||||
|
||||
- Surface matches the parent card (`border border-base-200 bg-base-100 rounded-lg`)
|
||||
- Height ~h-11
|
||||
- Centered: small icon chip + label
|
||||
- Icon chip: `bg-base-200 text-base-content/60 rounded-full h-5 w-5`
|
||||
- Hover: border deepens to `base-300`, bg lightens to `bg-base-200/60`, icon chip inverts
|
||||
to `bg-base-content text-base-100`
|
||||
- `eink-bordered` on the button itself
|
||||
|
||||
```tsx
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleAdd}
|
||||
className={clsx(
|
||||
'eink-bordered group flex h-11 items-center justify-center gap-2.5',
|
||||
'border-base-200 bg-base-100 rounded-lg border px-4',
|
||||
'text-base-content text-sm font-medium',
|
||||
'transition-colors duration-150',
|
||||
'hover:border-base-300 hover:bg-base-200/60',
|
||||
'active:bg-base-200/80',
|
||||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
'bg-base-200 text-base-content/60',
|
||||
'transition-colors duration-150',
|
||||
'group-hover:bg-base-content group-hover:text-base-100',
|
||||
)}
|
||||
>
|
||||
<MdAdd className='h-3.5 w-3.5' />
|
||||
</span>
|
||||
<span className='line-clamp-1'>{label}</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
Use this for: "Import Dictionary", "Add Web Search", "Add Custom Theme", any "+ add new
|
||||
to this list" pattern. **Do not** use `btn-outline btn-primary` for these.
|
||||
|
||||
---
|
||||
|
||||
### 5. Boxed list anatomy
|
||||
|
||||
The settings UI is built on boxed lists. One pattern, used everywhere.
|
||||
|
||||
#### Container
|
||||
|
||||
Use the `<BoxedList>` primitive at `src/components/settings/primitives/BoxedList.tsx`
|
||||
rather than inlining the chassis classes:
|
||||
|
||||
```tsx
|
||||
<BoxedList title={_('Reading Sync')} data-setting-id='settings.section.id'>
|
||||
{/* rows */}
|
||||
</BoxedList>
|
||||
```
|
||||
|
||||
The primitive renders:
|
||||
|
||||
```tsx
|
||||
<div className='card eink-bordered border-base-200 bg-base-100 border'>
|
||||
<div className='divide-base-200 divide-y'>{children}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `card` for the radius
|
||||
- `border border-base-200` for the boundary (eink upgrades this automatically)
|
||||
- `eink-bordered` for the e-ink-mode contrast border
|
||||
- `divide-base-200 divide-y` for inter-row separators
|
||||
|
||||
> **No `overflow-hidden` on the card.** Children may host popovers (color
|
||||
> pickers, dropdowns, tooltips) that need to escape the card bounds. The
|
||||
> `divide-y` rules sit between rows and don't touch the card's rounded
|
||||
> corners, so omitting overflow-clip is visually safe AND keeps embedded
|
||||
> popovers from getting clipped.
|
||||
|
||||
#### Row anatomy
|
||||
|
||||
Three slots, in order, always:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [prefix] Title text [suffix slots] │
|
||||
│ [ ] Subtitle text (optional) [ ][ ]│
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Slot | Contents |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------- |
|
||||
| **Prefix** | Drag handle, leading icon, avatar, status dot, or empty. |
|
||||
| **Title** | Primary label. `font-medium`. Truncates with `truncate`. |
|
||||
| **Subtitle** | Optional secondary line. `text-sm text-base-content/70`. Used for warnings, descriptions, status. |
|
||||
| **Suffix** | Badge, switch, button, chevron, value, or any combination. End-aligned. |
|
||||
|
||||
Canonical example: `SortableRow` in `src/components/settings/CustomDictionaries.tsx`. The
|
||||
drag handle is the prefix, the dict name is the title, the warning reason is the
|
||||
subtitle, and the badge + toggle + edit/delete buttons stack as suffixes.
|
||||
|
||||
#### Row variants
|
||||
|
||||
- **ActionRow** — title + suffix is a single button or chevron. Tap anywhere navigates.
|
||||
- **SwitchRow** — title + suffix is a toggle. Tap anywhere toggles.
|
||||
- **ComboRow** — title + suffix is a dropdown/select.
|
||||
- **ExpanderRow** — chevron suffix; tap expands to reveal nested rows.
|
||||
|
||||
These names come from libadwaita and apply 1:1 to Readest's lists. Use the names in code
|
||||
comments and PR descriptions.
|
||||
|
||||
#### Spacing
|
||||
|
||||
- Row vertical padding: `py-2` (8px) for compact lists, `py-3` (12px) for breathing room.
|
||||
- Row horizontal padding: `px-3` (12px) or `px-4` (16px). Stay consistent within a list.
|
||||
- Slot gap: `gap-2` (8px) between prefix/title/suffix elements.
|
||||
|
||||
#### Disabled rows
|
||||
|
||||
Disabled rows fade the title to `text-base-content/60` and disable the suffix control. The
|
||||
row itself stays at full opacity — only the **content** dims, not the row.
|
||||
|
||||
#### Toggle size
|
||||
|
||||
| Daisyui class | Use case |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `toggle` (default, h-5 / ~20px) | **Settings panel boxed-list rows** — `<SettingsSwitchRow>` uses this. Visible weight matches the 56px `min-h-14` row. |
|
||||
| `toggle-sm` (h-4 / ~16px) | Inline secondary switches in tighter contexts — e.g. dictionary list rows in `CustomDictionaries`. |
|
||||
| `toggle-xs` (h-3 / ~12px) | Compact metadata toggles inside cards — e.g. OPDS catalog "Auto-download". |
|
||||
|
||||
The `<SettingsSwitchRow>` primitive bakes in the default `toggle`. **Don't override
|
||||
to `toggle-sm` inside boxed-list rows** — it looks orphaned in the row's vertical
|
||||
breathing room. Use the smaller sizes only when the row itself is shorter than 56px.
|
||||
|
||||
#### Typography inherits from `.settings-content`
|
||||
|
||||
The Settings dialog (and any settings-style sheet/popup) wraps its content
|
||||
in `.settings-content`, which is defined in `src/styles/globals.css` as:
|
||||
|
||||
```css
|
||||
.dropdown-content,
|
||||
.settings-content {
|
||||
font-size: 14px; /* desktop */
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.dropdown-content,
|
||||
.settings-content {
|
||||
font-size: 16px; /* mobile bump — high-DPI phones need bigger body text */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Don't hardcode `text-sm` on row labels, NavigationRow titles, or panel
|
||||
descriptions** — that locks the text to 14px on every viewport and kills
|
||||
the mobile bump. Instead:
|
||||
|
||||
- **Primary labels** (SettingsRow label, NavigationRow title, SubPageHeader
|
||||
description, ad-hoc row labels in panels and integration forms): no
|
||||
font-size class — inherits 14/16 from the wrapper. Use `<SettingLabel>`
|
||||
rather than inlining a `<span>`; it adds `font-medium` for cased scripts
|
||||
and drops the weight for caseless scripts (CJK / Arabic / Hebrew / Indic
|
||||
/ Thai / Tibetan), since those bold poorly at body size and `font-medium`
|
||||
on Han / Hangul / Devanagari renders as uneven stroke-thickening across
|
||||
system fonts.
|
||||
- **Secondary text** (SettingsRow description, NavigationRow status, Tips
|
||||
body, BoxedList description): use `text-[0.85em]` so it stays
|
||||
proportional (≈12px desktop, ≈13.6px mobile).
|
||||
- **Form controls** (`<input>`, `<select>`): browsers don't inherit
|
||||
font-size onto form elements, so add the `settings-content` class
|
||||
_directly on the element_ to re-apply the 14/16 cascade. The legacy
|
||||
NumberInput already does this — match its pattern.
|
||||
- **Section headers** (`BoxedList` uppercase title): use `text-[0.85em]
|
||||
font-semibold uppercase tracking-wider`. The em-relative size keeps it
|
||||
proportional with the `.settings-content` cascade. **Caseless-script
|
||||
exception:** when `isCaselessUILang()` is true, bump to `text-[1em]`.
|
||||
The `uppercase` rule is a no-op in scripts without case (CJK, Arabic,
|
||||
Hebrew, Devanagari/Bengali/Tamil/Sinhala, Thai, Tibetan), so the size
|
||||
has to carry the emphasis those scripts can't pick up from casing. The
|
||||
helper lives in `src/utils/misc.ts`; the underlying `isCaselessLang`
|
||||
predicate lists every covered language code in `src/utils/lang.ts`.
|
||||
|
||||
Why this matters: Tailwind's `text-xs` / `text-sm` are rem-based — they
|
||||
ignore the parent's `font-size` because rem is rooted at the document.
|
||||
The `.settings-content` cascade is in `px`, so any child that picks a
|
||||
Tailwind size literally tunes itself to the desktop default and never
|
||||
grows on mobile. iOS and Android have small physical screens but high
|
||||
DPI, so the mobile bump is what makes the text legible at typical reading
|
||||
distance.
|
||||
|
||||
#### Uniform row height
|
||||
|
||||
Settings rows in a boxed list MUST all be the same visual height. Use
|
||||
`min-h-14 items-center` (56px) on each row container — toggle, select, and
|
||||
input rows then center their controls vertically inside identical boxes.
|
||||
**Don't use `py-3`** — content-driven padding produces uneven heights
|
||||
because toggles, selects (`h-9`), and inputs (`h-9`) have different
|
||||
intrinsic sizes.
|
||||
|
||||
```tsx
|
||||
// ✓ Right — no text-sm; label inherits .settings-content (14/16)
|
||||
<label className='flex min-h-14 items-center justify-between px-4'>
|
||||
<span className='font-medium'>{_('Sync Enabled')}</span>
|
||||
<input type='checkbox' className='toggle' ... />
|
||||
</label>
|
||||
|
||||
// ✗ Wrong — toggle row will be 48px, select rows 60px
|
||||
<label className='flex items-center justify-between px-4 py-3'>...</label>
|
||||
|
||||
// ✗ Wrong — text-sm hardcodes 14px even on mobile (kills the bump)
|
||||
<span className='text-sm font-medium'>{_('Sync Enabled')}</span>
|
||||
```
|
||||
|
||||
#### Controls inside a boxed list have no chrome
|
||||
|
||||
When a control sits inside a bordered card, it shouldn't carry its own
|
||||
border or fill. The card supplies the visual boundary; the control just
|
||||
sits on the row.
|
||||
|
||||
- **Selects:** drop `select-bordered` and `eink-bordered`. Add
|
||||
`!bg-transparent !bg-none !appearance-none` to suppress daisyui's
|
||||
background chevron and native arrow. Render a real `<MdArrowDropDown>`
|
||||
icon at the cell's trailing edge for the affordance — see "End-aligned
|
||||
values" below.
|
||||
- **Inputs:** drop `input-bordered` and `eink-bordered`. Add `!bg-transparent`
|
||||
with `hover:!bg-base-200/60 focus:!bg-base-200/60` so the field still
|
||||
signals interactability. Use `text-end` and `!pe-0` so the value sits
|
||||
flush against the row's trailing edge.
|
||||
- **Toggles:** untouched — they're already chromeless.
|
||||
|
||||
This is the iOS Settings / Adwaita PreferencesGroup convention: list
|
||||
chrome belongs to the container, not its children.
|
||||
|
||||
#### End-aligned values + chevron alignment
|
||||
|
||||
The selected value of a select/input MUST end-align (`text-end`). The
|
||||
**visible right edge** of every row's value (toggle, chevron icon, input
|
||||
text) MUST land at the same X — the row's trailing padding.
|
||||
|
||||
The trap: daisyui's select renders its chevron via background-image at
|
||||
`calc(100% - 1rem) center`, which floats the glyph 16px _inside_ the
|
||||
select's right edge. So if the toggle in row 1 ends at the row's `pe-4`
|
||||
edge, the chevron in row 2 ends 16px before that — visibly misaligned.
|
||||
|
||||
**Fix:** suppress daisyui's bg-image chevron and render an explicit icon at
|
||||
the cell's trailing edge. The select's own daisyui focus chrome (outline +
|
||||
box-shadow + ring) is suppressed; **no focus ring** on controls inside the
|
||||
boxed list — focus state is signaled by a subtle wrapper bg-shift instead
|
||||
(hover and focus-within both lift to `bg-base-200/60`). Rings would compete
|
||||
with the card's own border and double-stack with adjacent rows.
|
||||
|
||||
```tsx
|
||||
<div className='hover:bg-base-200/60 focus-within:bg-base-200/60 flex max-w-[60%] items-center rounded-md'>
|
||||
<select className='select h-9 min-w-0 cursor-pointer !appearance-none truncate !border-0 !bg-transparent !bg-none !pe-1 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0'>
|
||||
{/* options */}
|
||||
</select>
|
||||
<MdArrowDropDown
|
||||
aria-hidden='true'
|
||||
className='text-base-content/55 pointer-events-none h-5 w-5 flex-shrink-0'
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
> **Why so many `!` overrides?** daisyui's `.select` and `.input` apply
|
||||
> `border-width: 1px` + `border-color` (transparent at rest, `var(--bc)` on
|
||||
> focus), plus `outline`, `box-shadow`, and `ring` chrome on focus. To make
|
||||
> the control truly chromeless inside a boxed list, you need to kill all
|
||||
> four properties. Missing any of them — especially `border-0` — leaves a
|
||||
> visible focus border leaking through.
|
||||
|
||||
The `<MdArrowDropDown>` icon's trailing edge now lives at the same X as the
|
||||
toggle's trailing edge in adjacent rows, because both are flush with the
|
||||
row's `pe-4` padding.
|
||||
|
||||
For inputs, no wrapper is needed — the input is one element, so put the
|
||||
hover/focus bg directly on it. Suppress daisyui's own focus chrome the
|
||||
same way:
|
||||
|
||||
```tsx
|
||||
<input className='input hover:!bg-base-200/60 focus:!bg-base-200/60 h-9 max-w-[60%] rounded-md !border-0 !bg-transparent !pe-0 !ps-2 text-end text-sm focus:!border-0 focus:!shadow-none focus:!outline-none focus:!ring-0' />
|
||||
```
|
||||
|
||||
> **Why no ring here when §2.7 says "focus needs a visible ring"?** §2.7 is
|
||||
> for standalone custom buttons (Submit, Cancel, ListExtension, etc.). In a
|
||||
> boxed list, the row already provides strong visual containment via the
|
||||
> card border + dividers, and stacking a per-control ring inside that
|
||||
> creates double chrome. The bg-shift IS the focus indicator — keyboard
|
||||
> users still get clear feedback; the surface stays calm.
|
||||
|
||||
---
|
||||
|
||||
### 6. Header bars, dialogs, popups, sheets
|
||||
|
||||
#### Header bar
|
||||
|
||||
The dialog/page header. Adwaita's AdwHeaderBar.
|
||||
|
||||
- **48–56px tall** (`h-12` to `h-14`).
|
||||
- **Center-aligned title** in `font-semibold text-base`.
|
||||
- **Leading slot**: back chevron (mobile) or empty (desktop).
|
||||
- **Trailing slot**: window controls — search (pill ghost), menu (pill ghost),
|
||||
close (pill ghost circle with `bg-base-300/65`).
|
||||
- No bottom border; rely on tab/divider that follows.
|
||||
|
||||
`SettingsDialog.tsx`'s mobile header is the canonical example. The desktop header is
|
||||
slightly different — tabs sit in the same row as window controls, no center title — but
|
||||
it's the same archetype adapted for screen real estate.
|
||||
|
||||
#### Dialog (modal)
|
||||
|
||||
```tsx
|
||||
<Dialog
|
||||
isOpen={...}
|
||||
onClose={...}
|
||||
boxClassName="sm:min-w-[520px] overflow-hidden"
|
||||
header={<HeaderBar />}
|
||||
>
|
||||
{/* content */}
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
- `modal-box` provides the radius, max-width, and shadow (auto-removed in eink).
|
||||
- Width ~520px on desktop, full-width on mobile.
|
||||
- Bottom sheets on mobile via `snapHeight` prop.
|
||||
- Backdrop: `sm:!bg-black/50` (or `/20` when nested over a darker surface).
|
||||
|
||||
#### Popup (popover)
|
||||
|
||||
For dictionary lookups, annotation editors, and other anchored overlays. Uses the
|
||||
`Popup` component with a triangle pointer.
|
||||
|
||||
- **Width**: clamp to fit content; ~320–420px typical.
|
||||
- **Surface**: `bg-base-100`, `rounded-lg`, soft shadow (eink removes shadow).
|
||||
- **Triangle**: pointer toward the anchor; eink has special triangle classes.
|
||||
- **Padding**: `p-3` to `p-4` for content.
|
||||
|
||||
#### Sheet (mobile bottom)
|
||||
|
||||
Reserved for mobile contextual menus and full-screen secondary panels. Uses the dialog's
|
||||
`snapHeight` prop. Adwaita doesn't have a native sheet but Readest's mobile pattern is
|
||||
the closest analog.
|
||||
|
||||
- Always full-width.
|
||||
- Top corners rounded; bottom corners flat (it's anchored to the bottom).
|
||||
- Drag handle at top (the small horizontal pill) is mandatory if the sheet supports
|
||||
swipe-to-dismiss.
|
||||
|
||||
---
|
||||
|
||||
### 7. Motion + a11y
|
||||
|
||||
#### Motion
|
||||
|
||||
- Default duration: **150ms** for color transitions.
|
||||
- Default easing: browser default (`ease`) or `ease-out`. Never `ease-in`.
|
||||
- Longer transitions (300ms+) only for layout changes (sheet snap, panel slide).
|
||||
- **Never** use `transform` for hover unless the transform IS the message
|
||||
(chevron rotation, drag-handle drag visualization). E-ink doesn't render mid-transitions
|
||||
cleanly and Adwaita's identity is calm.
|
||||
|
||||
```tsx
|
||||
// Good — hover:bg-base-200 with transition-colors
|
||||
className = 'transition-colors duration-150 hover:bg-base-200';
|
||||
|
||||
// Bad — scale on hover
|
||||
className = 'transition-transform hover:scale-105';
|
||||
```
|
||||
|
||||
Existing exceptions: `.window-button` in globals.css uses `hover:scale-105`. That's
|
||||
legacy; new code shouldn't follow it.
|
||||
|
||||
#### Reduced motion
|
||||
|
||||
Reduced-motion preference is honored via the `no-transitions` class
|
||||
(`globals.css:624`). Layout-changing transitions should respect
|
||||
`prefers-reduced-motion: reduce` either via this class or `motion-safe:` Tailwind
|
||||
prefixes.
|
||||
|
||||
#### Focus
|
||||
|
||||
- Every focusable element must have a visible focus indicator.
|
||||
- Custom buttons:
|
||||
`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-content/15`.
|
||||
- Inputs: rely on daisyui's input focus ring; inputs with custom styling use
|
||||
`focus:ring-2 focus:ring-primary/40`.
|
||||
- Don't use `outline-none` without `focus-visible:` replacement.
|
||||
|
||||
#### Hit targets
|
||||
|
||||
- **Minimum**: 32px (the size of `btn-sm`).
|
||||
- **Recommended**: 40px (`btn`) on touch surfaces.
|
||||
- **Mobile**: 44px+ for taps that aren't fail-safe (delete, navigate-away).
|
||||
- The `touch-target` class in globals.css extends a small visual control's hit area to
|
||||
44px without changing its rendered size — use it on icon-sized buttons in mobile UIs.
|
||||
|
||||
#### Color contrast
|
||||
|
||||
- Body text on background: WCAG AA (4.5:1) minimum.
|
||||
- Large text: WCAG AA Large (3:1) minimum.
|
||||
- Interactive text on hover state: still passes contrast on the new background.
|
||||
- Theme palette is generated from `(bg, fg, primary)`; the tinycolor pipeline keeps
|
||||
contrast within range, but custom themes can break this — Settings → Color flags
|
||||
low-contrast custom themes.
|
||||
|
||||
#### Keyboard
|
||||
|
||||
- Tab order matches visual order. If you use `flex-row-reverse` for visual layout,
|
||||
consider `tabIndex` to fix order.
|
||||
- Modal focus trap: `<Dialog>` handles this.
|
||||
- Esc to dismiss: `<Dialog>` and `<Popup>` handle this.
|
||||
- Arrow keys for grouped controls (radio-like tab strips, sortable lists). dnd-kit's
|
||||
`KeyboardSensor` is wired for sortable lists.
|
||||
|
||||
---
|
||||
|
||||
### 8. E-ink overlay (cross-cutting)
|
||||
|
||||
E-ink mode is toggled by `[data-eink='true']` on the document. It applies a global
|
||||
override layer in `src/styles/globals.css:484-622` that:
|
||||
|
||||
- Removes all `box-shadow`.
|
||||
- Forces `text-base-content`, `text-blue-*`, `text-red-*`, `text-neutral-content` to a
|
||||
single foreground color.
|
||||
- Inverts `btn-primary` and `btn-outline` to base-content bg + base-100 text.
|
||||
- Adds 1px contrast borders to `.eink-bordered`, `.modal-box`, `.menu-container`,
|
||||
`.popup-container`, `.alert`, `.opds-navigation .card`, `.booknote-item`,
|
||||
`.bookitem-main`.
|
||||
|
||||
What this means for new components:
|
||||
|
||||
| Surface type | Required class | Why |
|
||||
| ------------------------------------ | ----------------------- | ------------------------------------------------------------- |
|
||||
| Custom bordered button or input | `eink-bordered` | Gets the 1px contrast border in eink |
|
||||
| Primary CTA | `btn-primary` | Picks up the inverted treatment |
|
||||
| Cancel / secondary action | `btn-ghost` (no border) | Reads as "outlined" only after pairing with the CTA |
|
||||
| Card / panel using `border-base-200` | `eink-bordered` | Otherwise the soft border vanishes in eink |
|
||||
| Modal / Popup | (auto) | `modal-box` and `.popup-container` are handled in globals.css |
|
||||
|
||||
Verification checklist before shipping a new UI:
|
||||
|
||||
- [ ] Toggle Settings → Misc → Eink mode and re-test every screen.
|
||||
- [ ] Every container that has a soft border (`border-base-200`) still has visible
|
||||
delineation.
|
||||
- [ ] Every CTA is distinguishable from its neighbors (cancel, secondary).
|
||||
- [ ] No hover transforms make the UI feel jumpy.
|
||||
- [ ] Text is fully opaque (no `text-base-content/60` content; eink can't render the
|
||||
reduced opacity well).
|
||||
|
||||
#### What's NOT compatible with e-ink
|
||||
|
||||
- Drop shadows for hierarchy (use borders).
|
||||
- Color-only state changes (use border weight or fill swap).
|
||||
- Hover scale / translate (they look broken on slow refresh).
|
||||
- Animations longer than ~200ms (visible refresh artifacts).
|
||||
|
||||
---
|
||||
|
||||
### 9. Cross-platform grace notes
|
||||
|
||||
Readest ships on **macOS, Windows, Linux, iOS, Android, web**. Adwaita is desktop-GNOME-
|
||||
native; we adapt where the host OS has strong conventions, but never at the cost of
|
||||
identity.
|
||||
|
||||
#### iOS
|
||||
|
||||
- Slightly larger corner radii feel native (`rounded-xl` on dialogs, `rounded-lg` on
|
||||
cards).
|
||||
- Safe area insets are mandatory for top + bottom anchored elements (see
|
||||
`docs/safe-area-insets.md`).
|
||||
- Avoid Material Design ripple effects.
|
||||
- Sheet-style modals (bottom-anchored) match iOS conventions and are preferred over
|
||||
centered dialogs on phone-sized screens.
|
||||
|
||||
#### Android
|
||||
|
||||
- Material 3 conventions that conflict with Adwaita (FABs, elevation shadows, ripple
|
||||
inks): **don't** copy them. Readest's identity is Adwaita; the user is reading on
|
||||
Android, not in Android.
|
||||
- Touch targets bumped to 48px for primary actions (Material's recommended target).
|
||||
- Back-gesture-aware UIs: ensure swipe-from-edge doesn't conflict with horizontal swipe
|
||||
controls.
|
||||
|
||||
#### Linux
|
||||
|
||||
- Native Adwaita territory. Readest can match host theme for window chrome (Tauri
|
||||
decorations) but should keep its own internal palette for the reading surface — book
|
||||
themes (sepia, gruvbox, etc.) are user choices, not OS choices.
|
||||
|
||||
#### macOS / Windows
|
||||
|
||||
- Window controls (close/minimize/maximize) are platform-native via Tauri.
|
||||
- Title bar height matches platform convention; internal layout follows Readest's
|
||||
Adwaita palette.
|
||||
|
||||
#### Web
|
||||
|
||||
- No safe-area insets needed.
|
||||
- Keyboard shortcuts are doubled with command-palette discoverability (Cmd/Ctrl+K).
|
||||
- Browser-native focus rings: respected, augmented with `focus-visible:ring-*`.
|
||||
|
||||
#### E-ink readers (Android-based, custom firmware)
|
||||
|
||||
- Detected via the eink mode toggle (Settings → Misc).
|
||||
- All rules in §8 apply.
|
||||
- This is a **first-class** target, not a fallback.
|
||||
|
||||
---
|
||||
|
||||
### 10. Anti-patterns
|
||||
|
||||
Things that LOOK fine in isolation but break the system. Each one has a real source diff
|
||||
or commit reference.
|
||||
|
||||
#### 10.1 Loud outlined CTAs for non-primary actions
|
||||
|
||||
```tsx
|
||||
// Anti-pattern (was in CustomDictionaries.tsx, fixed Nov 2026):
|
||||
<button className='btn btn-outline btn-primary gap-2 normal-case [--animation-btn:0s]'>
|
||||
<MdAdd className='h-5 w-5' />
|
||||
Import Dictionary
|
||||
</button>
|
||||
|
||||
// Correct: ListExtension archetype (see §4.6)
|
||||
```
|
||||
|
||||
Why it broke: the buttons read as primary CTAs but are list extensions. They competed
|
||||
with the active settings tab indicator and pulled the eye from the list itself.
|
||||
|
||||
#### 10.2 Recoloring the whole button on hover
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<button className="text-base-content/70 hover:text-base-content hover:bg-primary/10">
|
||||
|
||||
// Correct: keep the label color steady, hover via bg shift on the surface
|
||||
<button className="text-base-content hover:bg-base-200 transition-colors">
|
||||
```
|
||||
|
||||
Why: principle 2.4 (localize the hover signal). Whole-button color shifts feel decorative.
|
||||
|
||||
#### 10.3 Transform-based hover
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<button className="hover:scale-105 transition-transform">
|
||||
|
||||
// Correct: color/border-based hover
|
||||
<button className="hover:bg-base-200 hover:border-base-300 transition-colors">
|
||||
```
|
||||
|
||||
Why: breaks under e-ink (§2.5), feels jumpy under Adwaita's calm rhythm.
|
||||
|
||||
#### 10.4 Soft borders without `eink-bordered`
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<div className="border border-base-200 bg-base-100 rounded-lg p-4">
|
||||
...
|
||||
</div>
|
||||
|
||||
// Correct:
|
||||
<div className="eink-bordered border border-base-200 bg-base-100 rounded-lg p-4">
|
||||
...
|
||||
</div>
|
||||
```
|
||||
|
||||
Why: in e-ink mode, `base-200` borders disappear into the background. `eink-bordered`
|
||||
flips the border to `base-content` so the boundary stays visible.
|
||||
|
||||
Exception: containers that **don't** need a visible boundary in eink (e.g., a
|
||||
`bg-base-100` surface that's already against `bg-base-200`) can skip `eink-bordered`.
|
||||
The class is opt-in for "this surface needs a border to read correctly".
|
||||
|
||||
#### 10.5 Reduced-opacity text in e-ink
|
||||
|
||||
```tsx
|
||||
// Anti-pattern (in eink):
|
||||
<span className="text-base-content/50">Optional metadata</span>
|
||||
|
||||
// Correct (still readable in eink):
|
||||
<span className="text-base-content text-xs">Optional metadata</span>
|
||||
// Or use semantic muting that the eink overlay handles:
|
||||
<span className="text-neutral-content">Optional metadata</span>
|
||||
```
|
||||
|
||||
Why: e-ink's reduced color depth turns `/50` opacity into illegible mush. Use size or
|
||||
weight for hierarchy on muted secondary text.
|
||||
|
||||
#### 10.6 Daisyui `btn` defaults without intent
|
||||
|
||||
```tsx
|
||||
// Anti-pattern: just reaching for `btn` with no role:
|
||||
<button className="btn">Click me</button>
|
||||
|
||||
// Correct: pick an archetype from §4.
|
||||
<button className="btn btn-ghost">Cancel</button> // Flat
|
||||
<button className="btn btn-primary">Save</button> // Accent CTA
|
||||
```
|
||||
|
||||
Why: daisyui's `btn` default isn't tuned for any specific role. Pick from the action
|
||||
vocabulary so the button signals its weight in the surface hierarchy.
|
||||
|
||||
#### 10.7 Ad-hoc surface tokens
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<div className="bg-white border-gray-200">
|
||||
|
||||
// Correct:
|
||||
<div className="bg-base-100 border-base-200">
|
||||
```
|
||||
|
||||
Why: hard-coded colors don't theme. Readest has 11 themes plus user-defined custom themes.
|
||||
Always use the daisyui semantic tokens.
|
||||
|
||||
#### 10.8 Mixing `btn` sizes within a surface
|
||||
|
||||
```tsx
|
||||
// Anti-pattern:
|
||||
<header>
|
||||
<button className="btn btn-sm">Search</button>
|
||||
<button className="btn btn-md">Settings</button>
|
||||
<button className="btn btn-xs">Close</button>
|
||||
</header>
|
||||
|
||||
// Correct: one size per surface
|
||||
<header>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Search</button>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Settings</button>
|
||||
<button className="btn btn-ghost btn-circle h-8 min-h-8 w-8">Close</button>
|
||||
</header>
|
||||
```
|
||||
|
||||
Why: visual rhythm. Mixed sizes feel like the surface is unfinished.
|
||||
|
||||
---
|
||||
|
||||
### 11. Quick reference
|
||||
|
||||
When designing a new surface, walk this checklist:
|
||||
|
||||
1. **What's the surface tier?** Window / View / Card. (§3)
|
||||
2. **What's the corner radius?** Match the tier. (§3)
|
||||
3. **Is there a primary action?** If yes, ONE accent CTA. If no, all flats. (§4.1, §4.3)
|
||||
4. **Are there list extensions?** Use the ListExtension archetype, not `btn-outline btn-primary`. (§4.6)
|
||||
5. **Is it a list?** Use the BoxedList chassis with ActionRow / SwitchRow / ComboRow / ExpanderRow rows. (§5)
|
||||
6. **Does it need `eink-bordered`?** If it has a soft border that must stay visible in
|
||||
eink mode, yes. (§8)
|
||||
7. **Is the hover signal localized?** One focal element changes, not the whole control. (§2.4)
|
||||
8. **Is motion color-only?** No transforms unless the transform IS the message. (§2.5)
|
||||
9. **Is focus visible?** `focus-visible:ring-2 focus-visible:ring-base-content/15` on
|
||||
custom buttons. (§7)
|
||||
10. **Will it work on the smallest theme + e-ink?** Toggle Sepia + Eink, retest.
|
||||
|
||||
---
|
||||
|
||||
### 12. Glossary
|
||||
|
||||
- **Adwaita / libadwaita**: GNOME's design system and widget toolkit. Source of Readest's
|
||||
visual lineage.
|
||||
- **AdwActionRow / AdwSwitchRow / AdwComboRow / AdwExpanderRow**: libadwaita's row
|
||||
primitives. Readest mirrors these conceptually with custom React components.
|
||||
- **AdwBoxedList**: libadwaita's named container for grouped action rows.
|
||||
- **AdwBanner**: top-of-window inline alert (persistent).
|
||||
- **AdwToast**: bottom slide-in transient alert.
|
||||
- **Window / View / Card**: surface tiers (§3).
|
||||
- **ListExtension**: Readest-named archetype for "+ add new row" buttons (§4.6).
|
||||
- **eink-bordered**: utility class in `globals.css` that gives a surface its e-ink-mode
|
||||
contrast border. Opt-in.
|
||||
- **Pill ghost**: circular icon button, `btn-ghost btn-circle`.
|
||||
|
||||
---
|
||||
|
||||
### 13. Maintenance
|
||||
|
||||
This doc is the **source of truth** for new design decisions. When the system grows:
|
||||
|
||||
- New archetypes get a numbered subsection in §4 or §5.
|
||||
- New anti-patterns get added to §10 with a real source reference.
|
||||
- Updates to existing principles require a brief why-changed note in the relevant section.
|
||||
|
||||
Cross-references that must stay in sync:
|
||||
|
||||
- `CLAUDE.md` E-ink mode section → §8 of this doc.
|
||||
- `docs/safe-area-insets.md` → §9 (cross-platform).
|
||||
- `src/styles/globals.css` `[data-eink]` rules → §8.
|
||||
- `src/styles/themes.ts` Palette type → §3 token table.
|
||||
|
||||
If you change a rule here, search for the cross-reference and update both.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Benchmarks
|
||||
|
||||
Manual performance benchmarks for the readest-app. **Not run in CI** — CI runners
|
||||
have shared-tenant variance that makes performance regression detection unreliable
|
||||
(numbers swing 2-10× between runs). These exist so anyone considering an
|
||||
architecture change can produce reproducible before/after numbers on their own
|
||||
hardware.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
pnpm bench # run every bench/*.bench.ts
|
||||
pnpm bench vector-retrieval # run a single benchmark by name
|
||||
pnpm bench --no-record # run but don't append to bench/results.jsonl
|
||||
pnpm bench --list # list available benchmarks
|
||||
```
|
||||
|
||||
Refuses to run when `$CI` is set. Append `--force` to override (don't unless
|
||||
you've explicitly opted into running benches in CI for a one-off investigation).
|
||||
|
||||
## Output
|
||||
|
||||
Each run prints a header with machine info (platform, CPU, Node version, key
|
||||
package versions) followed by per-benchmark results. By default, results are
|
||||
also appended to `bench/results.jsonl` (gitignored) — your personal local
|
||||
history. To share numbers, paste the table from the terminal into a PR or issue.
|
||||
|
||||
## When to add a new benchmark
|
||||
|
||||
When you're proposing an architecture change and need numbers to defend it. The
|
||||
benchmark should:
|
||||
|
||||
1. Live at `bench/<name>.bench.ts`.
|
||||
2. Export `default { name, description, run(ctx) }` matching the type in `lib.ts`.
|
||||
3. Print human-readable results to stdout and return structured results to the
|
||||
harness so they get logged to `results.jsonl`.
|
||||
4. Be self-contained — no fixtures outside `bench/`, no I/O outside the bench
|
||||
directory and an in-memory database.
|
||||
5. Run in under ~30 seconds at default sample sizes. If you need long-running
|
||||
scenarios, gate them behind a CLI flag.
|
||||
|
||||
## When *not* to add a benchmark
|
||||
|
||||
- "Just in case" — performance infrastructure has carrying cost. Wait until
|
||||
you have a real architecture question that numbers will answer.
|
||||
- To benchmark upstream libraries' performance (e.g., raw Turso function
|
||||
throughput). That belongs in the upstream project's bench suite.
|
||||
- To gate CI on performance thresholds. CI variance makes that flaky; use
|
||||
production telemetry (`reedy_metrics` table) for regression detection
|
||||
against real workloads.
|
||||
|
||||
## Existing benchmarks
|
||||
|
||||
- **`vector-retrieval`** — proves Turso's brute-force vector search is
|
||||
SIMD-accelerated and fast enough for Reedy MVP corpus sizes (sub-millisecond
|
||||
at 400 chunks × 768 dim, ~14 ms at 10K chunks × 768 dim). Established the
|
||||
decision in plan §M1.5 to skip ANN indexes (which Turso doesn't ship anyway).
|
||||
@@ -1,115 +0,0 @@
|
||||
import { readdirSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
||||
import { join, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { type Bench, type BenchResult, formatHeader, formatResults, machineInfo } from './lib.ts';
|
||||
|
||||
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const RESULTS_FILE = join(BENCH_DIR, 'results.jsonl');
|
||||
|
||||
interface Args {
|
||||
filter: string | null;
|
||||
record: boolean;
|
||||
list: boolean;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = { filter: null, record: true, list: false, force: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === '--no-record') args.record = false;
|
||||
else if (arg === '--list') args.list = true;
|
||||
else if (arg === '--force') args.force = true;
|
||||
else if (!arg.startsWith('--')) args.filter = arg;
|
||||
else {
|
||||
console.error(`Unknown flag: ${arg}`);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function loadBenches(): Promise<Bench[]> {
|
||||
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith('.bench.ts'));
|
||||
const benches: Bench[] = [];
|
||||
for (const file of files) {
|
||||
const mod = await import(resolve(BENCH_DIR, file));
|
||||
const bench = mod.default as Bench;
|
||||
if (!bench || typeof bench.run !== 'function') {
|
||||
console.error(`Skipping ${file}: no default export with .run()`);
|
||||
continue;
|
||||
}
|
||||
benches.push(bench);
|
||||
}
|
||||
return benches.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function gitCommit(): string {
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function recordRun(bench: Bench, results: BenchResult[], commit: string): void {
|
||||
if (!existsSync(BENCH_DIR)) mkdirSync(BENCH_DIR, { recursive: true });
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
commit,
|
||||
bench: bench.name,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
node: process.version,
|
||||
results,
|
||||
};
|
||||
appendFileSync(RESULTS_FILE, JSON.stringify(entry) + '\n');
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (process.env['CI'] && !args.force) {
|
||||
console.error('Refusing to run benchmarks in CI (CI=' + process.env['CI'] + ').');
|
||||
console.error('Pass --force if you really mean it. See bench/README.md for why.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const benches = await loadBenches();
|
||||
|
||||
if (args.list) {
|
||||
console.log('Available benchmarks:');
|
||||
for (const b of benches) console.log(` ${b.name.padEnd(20)} ${b.description}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = args.filter ? benches.filter((b) => b.name === args.filter) : benches;
|
||||
if (selected.length === 0) {
|
||||
console.error(`No benchmark named "${args.filter}". Try --list.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const info = machineInfo(['@tursodatabase/database', '@readest/turso-database-wasm']);
|
||||
console.log(formatHeader(info));
|
||||
|
||||
const commit = gitCommit();
|
||||
console.log(` git commit : ${commit}`);
|
||||
console.log(` recording : ${args.record ? RESULTS_FILE : 'disabled (--no-record)'}`);
|
||||
console.log('═'.repeat(70));
|
||||
|
||||
for (const bench of selected) {
|
||||
process.stdout.write(`Running ${bench.name}... `);
|
||||
const t0 = performance.now();
|
||||
const results = await bench.run({ verbose: false });
|
||||
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||
console.log(`done in ${elapsed}s`);
|
||||
console.log(formatResults(bench.name, results));
|
||||
if (args.record) recordRun(bench, results, commit);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,119 +0,0 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { cpus, platform, arch, totalmem } from 'node:os';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
export interface BenchContext {
|
||||
/** Whether to print verbose per-iteration info. */
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export interface BenchResult {
|
||||
scenario: string;
|
||||
unit: 'ms' | 'us' | 'ns';
|
||||
value: number;
|
||||
/** Optional metadata: chunk count, dim, etc. */
|
||||
meta?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface Bench {
|
||||
name: string;
|
||||
description: string;
|
||||
run(ctx: BenchContext): Promise<BenchResult[]>;
|
||||
}
|
||||
|
||||
/** High-resolution timer; returns elapsed milliseconds. */
|
||||
export async function timed<T>(fn: () => Promise<T>): Promise<{ result: T; ms: number }> {
|
||||
const t0 = performance.now();
|
||||
const result = await fn();
|
||||
const ms = performance.now() - t0;
|
||||
return { result, ms };
|
||||
}
|
||||
|
||||
/** Run `fn` `reps` times, return average milliseconds (after warmup). */
|
||||
export async function avg(fn: () => Promise<unknown>, reps: number, warmup = 3): Promise<number> {
|
||||
for (let i = 0; i < warmup; i++) await fn();
|
||||
const t0 = performance.now();
|
||||
for (let i = 0; i < reps; i++) await fn();
|
||||
return (performance.now() - t0) / reps;
|
||||
}
|
||||
|
||||
/** Generate a random unit vector serialized as JSON, suitable for `vector32(?)`. */
|
||||
export function randomUnitVectorJson(dim: number): string {
|
||||
const v = new Float32Array(dim);
|
||||
let norm = 0;
|
||||
for (let i = 0; i < dim; i++) {
|
||||
const x = Math.random() * 2 - 1;
|
||||
v[i] = x;
|
||||
norm += x * x;
|
||||
}
|
||||
const inv = 1 / Math.sqrt(norm);
|
||||
for (let i = 0; i < dim; i++) v[i] = (v[i] ?? 0) * inv;
|
||||
return JSON.stringify(Array.from(v));
|
||||
}
|
||||
|
||||
export interface MachineInfo {
|
||||
platform: string;
|
||||
arch: string;
|
||||
cpu: string;
|
||||
cpuCount: number;
|
||||
memGiB: number;
|
||||
node: string;
|
||||
packages: Record<string, string>;
|
||||
}
|
||||
|
||||
export function machineInfo(packages: string[] = []): MachineInfo {
|
||||
const cpuList = cpus();
|
||||
const firstCpu = cpuList[0];
|
||||
const versions: Record<string, string> = {};
|
||||
for (const name of packages) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(`./node_modules/${name}/package.json`, 'utf8'));
|
||||
versions[name] = pkg.version;
|
||||
} catch {
|
||||
versions[name] = 'not installed';
|
||||
}
|
||||
}
|
||||
return {
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
cpu: firstCpu?.model.trim() ?? 'unknown',
|
||||
cpuCount: cpuList.length,
|
||||
memGiB: Math.round(totalmem() / 1024 ** 3),
|
||||
node: process.version,
|
||||
packages: versions,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatHeader(info: MachineInfo): string {
|
||||
const lines = [
|
||||
'═'.repeat(70),
|
||||
` Platform : ${info.platform}/${info.arch}`,
|
||||
` CPU : ${info.cpu} (${info.cpuCount} cores)`,
|
||||
` Memory : ${info.memGiB} GiB`,
|
||||
` Node : ${info.node}`,
|
||||
];
|
||||
for (const [name, ver] of Object.entries(info.packages)) {
|
||||
lines.push(` ${name.padEnd(9)}: ${ver}`);
|
||||
}
|
||||
lines.push('═'.repeat(70));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatResults(benchName: string, results: BenchResult[]): string {
|
||||
const lines = [`\n[${benchName}]`];
|
||||
for (const r of results) {
|
||||
const metaStr = r.meta
|
||||
? ` ${Object.entries(r.meta)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(' ')}`
|
||||
: '';
|
||||
lines.push(` ${r.scenario.padEnd(40)} ${formatValue(r.value, r.unit).padStart(12)}${metaStr}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatValue(value: number, unit: 'ms' | 'us' | 'ns'): string {
|
||||
if (unit === 'ms') return `${value.toFixed(3)} ms`;
|
||||
if (unit === 'us') return `${value.toFixed(2)} µs`;
|
||||
return `${value.toFixed(0)} ns`;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { connect } from '@tursodatabase/database';
|
||||
import { avg, randomUnitVectorJson, type Bench, type BenchResult } from './lib.ts';
|
||||
|
||||
/**
|
||||
* Vector-retrieval brute-force kNN benchmark.
|
||||
*
|
||||
* Reedy MVP retrieval (see plan §M1.5) issues:
|
||||
*
|
||||
* SELECT id, vector_distance_cos(embedding, vector32(?)) AS d
|
||||
* FROM reedy_book_chunk_embeddings
|
||||
* WHERE book_hash = ?
|
||||
* ORDER BY d ASC LIMIT k
|
||||
*
|
||||
* Why this matters: Turso has no native vector index module
|
||||
* (`libsql_vector_idx` / `vector_top_k` don't exist — confirmed against
|
||||
* @tursodatabase/database@0.6.0-pre.28 and acknowledged upstream:
|
||||
* tursodatabase/turso#832 closed not-planned, #3778 proposed brute-force-first
|
||||
* which shipped at commit 1aba105df4f). The brute-force path with
|
||||
* SIMD-accelerated `vector_distance_cos` is what we ship; this bench tracks
|
||||
* its per-query latency at realistic MVP corpus sizes.
|
||||
*
|
||||
* Run it after upgrading @tursodatabase/database, after touching
|
||||
* BookRetriever's SQL shape, or when evaluating an architecture change
|
||||
* (ANN extension, quantization, engine swap).
|
||||
*/
|
||||
export default {
|
||||
name: 'vector-retrieval',
|
||||
description: 'Brute-force per-book kNN over vector32 embeddings filtered by book_hash.',
|
||||
|
||||
async run(): Promise<BenchResult[]> {
|
||||
const db = await connect(':memory:', {});
|
||||
await db.exec(
|
||||
'CREATE TABLE c (id INTEGER PRIMARY KEY, book_hash TEXT NOT NULL, embedding BLOB)',
|
||||
);
|
||||
await db.exec('CREATE INDEX idx_c_book ON c(book_hash)');
|
||||
|
||||
// (dim, chunks-per-book) matrix. Two books per scenario so the WHERE filter
|
||||
// does real work; we measure only the active-book query.
|
||||
const scenarios = [
|
||||
{ dim: 384, chunks: 400 }, // small book, light embedding (e5-small-v2)
|
||||
{ dim: 768, chunks: 400 }, // typical novel @ nomic-embed-text
|
||||
{ dim: 768, chunks: 2000 }, // long novel
|
||||
{ dim: 768, chunks: 10000 }, // multi-volume / textbook
|
||||
{ dim: 1536, chunks: 400 }, // text-embedding-3-small
|
||||
];
|
||||
|
||||
const results: BenchResult[] = [];
|
||||
|
||||
for (const { dim, chunks } of scenarios) {
|
||||
await db.exec('DELETE FROM c');
|
||||
|
||||
const insertA = await db.prepare(
|
||||
"INSERT INTO c (book_hash, embedding) VALUES ('book_a', vector32(?))",
|
||||
);
|
||||
for (let i = 0; i < chunks; i++) await insertA.run(randomUnitVectorJson(dim));
|
||||
|
||||
const insertB = await db.prepare(
|
||||
"INSERT INTO c (book_hash, embedding) VALUES ('book_b', vector32(?))",
|
||||
);
|
||||
for (let i = 0; i < chunks; i++) await insertB.run(randomUnitVectorJson(dim));
|
||||
|
||||
const query = randomUnitVectorJson(dim);
|
||||
// Embed the query vector literally so SIMD has the same memory layout
|
||||
// every call (mirrors the BookRetriever code path which serializes the
|
||||
// query embedding inline at the value-binding position).
|
||||
const sql = `
|
||||
SELECT id, vector_distance_cos(embedding, vector32('${query}')) AS d
|
||||
FROM c
|
||||
WHERE book_hash = ?
|
||||
ORDER BY d ASC
|
||||
LIMIT 5
|
||||
`;
|
||||
const stmt = await db.prepare(sql);
|
||||
|
||||
const ms = await avg(() => stmt.all('book_a'), 20);
|
||||
results.push({
|
||||
scenario: `${chunks} chunks × ${dim} dim`,
|
||||
unit: 'ms',
|
||||
value: ms,
|
||||
meta: { chunks, dim, usPerChunk: ((ms * 1000) / chunks).toFixed(2) },
|
||||
});
|
||||
}
|
||||
|
||||
await db.close();
|
||||
return results;
|
||||
},
|
||||
} satisfies Bench;
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/libs",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
# Readest Architecture
|
||||
|
||||
This document gives a system-level view of Readest: how the pieces fit together,
|
||||
which side of the wire each piece runs on, and what each module is responsible
|
||||
for. It complements [`code-layout.md`](./code-layout.md), which focuses on the
|
||||
directory layout. Read this one first if you want to understand the system; read
|
||||
that one when you need to find a specific file.
|
||||
|
||||
The diagrams use [Mermaid](https://mermaid.js.org/) and render natively on
|
||||
GitHub.
|
||||
|
||||
## 1. High-level picture
|
||||
|
||||
Readest is a single TypeScript/React codebase (`apps/readest-app`) compiled into
|
||||
multiple targets:
|
||||
|
||||
- a **desktop app** (Windows / macOS / Linux) via Tauri v2
|
||||
- a **mobile app** (Android / iOS) via Tauri v2 mobile
|
||||
- a **web app** running on Next.js / Cloudflare Workers (OpenNext) at
|
||||
[web.readest.com](https://web.readest.com)
|
||||
- two **side surfaces**: a "Send to Readest" browser extension
|
||||
(`apps/readest-app/extension/send-to-readest`) and a Windows thumbnail
|
||||
shell extension (`apps/readest-app/extensions/windows-thumbnail`)
|
||||
|
||||
The same React UI runs in all targets. What differs is the **host shell** under
|
||||
the UI and the **set of services** that the UI binds to at runtime — see
|
||||
section 4.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Clients
|
||||
Desktop["Desktop app<br/>(Tauri shell + React UI)"]
|
||||
Mobile["Mobile app<br/>(Tauri Android/iOS + React UI)"]
|
||||
Web["Web app<br/>(Next.js + React UI)"]
|
||||
Ext["Browser extension<br/>(Send to Readest)"]
|
||||
WinExt["Windows shell ext<br/>(thumbnail provider)"]
|
||||
end
|
||||
|
||||
subgraph Backend["Readest backend (Next.js routes + Cloudflare Worker)"]
|
||||
AppApi["src/app/api/*<br/>(App Router)"]
|
||||
PagesApi["src/pages/api/*<br/>(Pages Router)"]
|
||||
RuntimeCfg["/runtime-config.js<br/>(server-injected config)"]
|
||||
Worker["workers/send-email<br/>(Cloudflare Worker)"]
|
||||
end
|
||||
|
||||
subgraph Cloud["External services"]
|
||||
Supabase["Supabase<br/>(auth + Postgres)"]
|
||||
S3["Object storage<br/>(S3 / R2)"]
|
||||
Stripe["Stripe<br/>(billing)"]
|
||||
AI["AI providers<br/>(OpenAI / Ollama / ...)"]
|
||||
Trans["Translators<br/>(DeepL / Google / Azure / Yandex)"]
|
||||
Meta["Metadata providers<br/>(Google Books / Open Library)"]
|
||||
Dict["Dictionary sources<br/>(Wikipedia / Wiktionary / StarDict)"]
|
||||
OPDS["OPDS catalogs / Calibre"]
|
||||
Hardcover["Hardcover GraphQL"]
|
||||
Readwise["Readwise"]
|
||||
TTS["Edge TTS"]
|
||||
IAP["Apple / Google IAP"]
|
||||
end
|
||||
|
||||
Desktop --> Backend
|
||||
Mobile --> Backend
|
||||
Web --> Backend
|
||||
Ext --> PagesApi
|
||||
WinExt -.reads files.-> Desktop
|
||||
|
||||
PagesApi --> Supabase
|
||||
PagesApi --> S3
|
||||
PagesApi --> Trans
|
||||
AppApi --> Supabase
|
||||
AppApi --> Stripe
|
||||
AppApi --> AI
|
||||
AppApi --> Meta
|
||||
AppApi --> OPDS
|
||||
AppApi --> Hardcover
|
||||
AppApi --> TTS
|
||||
AppApi --> IAP
|
||||
|
||||
Web -.direct.-> Dict
|
||||
Desktop -.direct.-> Dict
|
||||
Mobile -.direct.-> Dict
|
||||
Web -.direct.-> Readwise
|
||||
Desktop -.direct.-> Readwise
|
||||
```
|
||||
|
||||
The `Backend` box is **the same code on all clients**. In the web target it is
|
||||
deployed as a Cloudflare Worker (via `@opennextjs/cloudflare` and
|
||||
`wrangler.toml`). In the Tauri targets the same routes are served by a Next.js
|
||||
runtime, but most clients hit the production deployment over HTTPS.
|
||||
|
||||
## 2. Process boundaries
|
||||
|
||||
There are three runtimes in play:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser["Web runtime (browser / Tauri webview)"]
|
||||
UI["React UI<br/>(src/app, src/components, src/hooks, src/store)"]
|
||||
Domain["Shared domain layer<br/>(src/services, src/utils, src/libs)"]
|
||||
Foliate["foliate-js<br/>(packages/foliate-js)"]
|
||||
SW["Service worker (sw.ts)"]
|
||||
TursoWasm["Turso WASM<br/>(replica DB in browser)"]
|
||||
end
|
||||
|
||||
subgraph Native["Tauri native host (Rust)"]
|
||||
TauriCore["src-tauri/src/lib.rs<br/>(commands, dir_scanner, transfer_file, clip_url, discord_rpc)"]
|
||||
Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, deep-link, opener, updater,<br/>native-bridge, native-tts, turso, webview-upgrade)"]
|
||||
end
|
||||
|
||||
subgraph Server["Next.js server (Worker / Node)"]
|
||||
Routes["App Router + Pages Router routes"]
|
||||
Mw["middleware.ts<br/>(CORS + COOP/COEP)"]
|
||||
RuntimeRoute["app/runtime-config.js<br/>(server-rendered config script)"]
|
||||
end
|
||||
|
||||
UI --> Domain
|
||||
Domain --> Foliate
|
||||
UI --> SW
|
||||
Domain --> TursoWasm
|
||||
|
||||
Domain -- "@tauri-apps/api invoke()" --> TauriCore
|
||||
TauriCore --> Plugins
|
||||
|
||||
Domain -- "fetch(/api/...)" --> Routes
|
||||
Browser -- "<script src=/runtime-config.js>" --> RuntimeRoute
|
||||
Routes --> Mw
|
||||
```
|
||||
|
||||
Three things are worth calling out:
|
||||
|
||||
The same `src/services/*` code runs on both sides of the `invoke()` boundary on
|
||||
desktop/mobile and on both sides of the `fetch()` boundary on web. Which
|
||||
implementation is picked is decided at runtime by `src/services/environment.ts`
|
||||
plus the platform-specific `*AppService.ts` (`webAppService`, `nativeAppService`,
|
||||
`nodeAppService`) — see section 4.
|
||||
|
||||
`middleware.ts` does two things and only two things: CORS for `/api/*`, and
|
||||
`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy:
|
||||
require-corp` on every document. The COOP/COEP pair is required so that the
|
||||
browser exposes `SharedArrayBuffer`, which the Turso WASM thread pool needs in
|
||||
order to run the in-browser replica database; without those headers
|
||||
`initThreadPool` hangs.
|
||||
|
||||
`/runtime-config.js` is a server route that emits
|
||||
`window.__READEST_RUNTIME_CONFIG = {...}` as a JavaScript file. It is loaded as
|
||||
a `<script>` tag from `app/layout.tsx` and `pages/_document.tsx`. This is what
|
||||
lets a single Docker image be rebranded with a different Supabase project, S3
|
||||
endpoint, or quota at deploy time without rebuilding — see commit
|
||||
`9ad43aa8` and the `docker/` directory.
|
||||
|
||||
## 3. Frontend architecture
|
||||
|
||||
The frontend is a Next.js 16 + React 19 app. It uses both routers:
|
||||
|
||||
| Concern | Lives in | Why |
|
||||
|---|---|---|
|
||||
| Library, reader, auth, OPDS, send, user pages | `src/app/*` (App Router) | Standard for new pages; supports server components and the runtime-config route. |
|
||||
| Reader entry by ID list `/reader/[ids]` | `src/pages/reader/[ids].tsx` (Pages Router) | Historical entrypoint; coexists with the App Router reader. |
|
||||
| Cross-origin isolation document shell | `src/pages/_document.tsx` | Pages Router still owns `<Document>` for COOP/COEP and `runtime-config.js`. |
|
||||
| HTTP API endpoints | both `src/app/api/*` and `src/pages/api/*` | Mix of new App Router routes and legacy Pages Router routes. |
|
||||
|
||||
### 3.1 UI module map
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Layout["app/layout.tsx<br/>(root shell, runtime-config script, Providers)"]
|
||||
Library["app/library<br/>(grid, import, sort, OPDS shelf)"]
|
||||
Reader["app/reader<br/>(views + tooling)"]
|
||||
Auth["app/auth<br/>(Supabase auth UI)"]
|
||||
Send["app/send<br/>(send-to-Readest inbox)"]
|
||||
User["app/user<br/>(account, subscription, settings)"]
|
||||
Updater["app/updater"]
|
||||
Offline["app/offline"]
|
||||
OPDS["app/opds<br/>(catalog browser)"]
|
||||
Share["app/s, app/o<br/>(share landing pages)"]
|
||||
|
||||
Layout --> Library
|
||||
Layout --> Reader
|
||||
Layout --> Auth
|
||||
Layout --> Send
|
||||
Layout --> User
|
||||
Layout --> OPDS
|
||||
|
||||
subgraph ReaderInternals["app/reader internals"]
|
||||
ReaderPage["page.tsx"]
|
||||
ReaderComps["components/*<br/>(BookView, Sidebar, Notebook,<br/>Annotator, FootnotePopup, Translator,<br/>RSVP overlay, AIChat, ParallelView, ...)"]
|
||||
ReaderHooks["hooks/*<br/>(useFoliateEvents, useScrollHandler,<br/>useProgressSync, useAnnotations, ...)"]
|
||||
ReaderUtils["utils/*"]
|
||||
end
|
||||
Reader --> ReaderInternals
|
||||
|
||||
subgraph Shared["Shared UI primitives"]
|
||||
Components["components/*<br/>(Button, Dialog, Menu, Toast,<br/>BookCover, AppLockScreen, ...)"]
|
||||
Settings["components/settings/*<br/>(Layout/Font/Color/Custom panels)"]
|
||||
Assistant["components/assistant/*<br/>(AI chat composer)"]
|
||||
CmdPalette["components/command-palette"]
|
||||
end
|
||||
```
|
||||
|
||||
The biggest UI cluster by far is `app/reader`: roughly 80 components and 30
|
||||
hooks coordinating Foliate-based rendering, annotations, footnote popovers, the
|
||||
notebook side panel, parallel view, RSVP, AI chat, translator overlays,
|
||||
search, TTS, and the settings panels under `components/settings`.
|
||||
|
||||
### 3.2 State (Zustand)
|
||||
|
||||
Frontend state is split across single-purpose Zustand stores in `src/store`.
|
||||
Each store maps to a clearly delimited concern, which keeps the reader from
|
||||
collapsing into one mega-context:
|
||||
|
||||
```
|
||||
libraryStore -> books, folders, selection, sort
|
||||
bookDataStore -> per-book data (TOC, annotations, locations)
|
||||
readerStore -> active views, layout, ribbon state
|
||||
parallelViewStore -> two-pane reading
|
||||
notebookStore -> notebook side panel
|
||||
settingsStore -> user/app settings
|
||||
themeStore -> light/dark/atmosphere
|
||||
sidebarStore -> sidebar visibility/width
|
||||
trafficLightStore -> macOS traffic-light positioning
|
||||
appLockStore -> app PIN lock
|
||||
deviceStore -> device profile
|
||||
transferStore -> in-flight uploads/downloads
|
||||
aiChatStore -> AI chat sessions
|
||||
proofreadStore -> proofread side flow
|
||||
atmosphereStore -> ambient overlay
|
||||
customDictionaryStore / customFontStore /
|
||||
customTextureStore / customOPDSStore -> user-imported assets
|
||||
```
|
||||
|
||||
### 3.3 In-browser book engine
|
||||
|
||||
EPUB / MOBI / KF8 / FB2 / CBZ / TXT / PDF parsing and rendering is **not**
|
||||
hand-rolled in this repo. The reader sits on top of `packages/foliate-js`, a
|
||||
forked copy of the Foliate JS engine. Readest's reader code in `app/reader` and
|
||||
the adapters under `src/services/annotation`, `src/services/nav`,
|
||||
`src/services/transformers`, and `src/services/rsvp` wrap that engine and add
|
||||
features (annotations sync, navigation, content transforms, vertical/Warichu
|
||||
support, classic mode overlays, etc.).
|
||||
|
||||
PDF rendering goes through `pdfjs-dist`, which is copied into
|
||||
`public/vendor/pdfjs` at build time (`pnpm setup-pdfjs`). Chinese conversion
|
||||
uses `simplecc-wasm` (`public/vendor/simplecc`), and Chinese segmentation uses
|
||||
`jieba-wasm` (`public/vendor/jieba`).
|
||||
|
||||
### 3.4 Service worker and offline
|
||||
|
||||
`src/sw.ts` is a Serwist service worker that gives the web build offline
|
||||
support: cached static assets, cached API responses for read-only data, and an
|
||||
offline route at `/offline`.
|
||||
|
||||
## 4. The platform abstraction (`AppService`)
|
||||
|
||||
The single most important abstraction in the codebase is
|
||||
`src/services/appService.ts`. Every piece of code that touches "the platform"
|
||||
(file system, native dialogs, shell open, native TTS, IAP, dir scanning,
|
||||
deep links, etc.) goes through an `AppService` interface. There are three
|
||||
implementations:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Caller["UI code, hooks, services"]
|
||||
AppSvc["AppService interface<br/>(services/appService.ts)"]
|
||||
Native["nativeAppService.ts<br/>(Tauri desktop + mobile)"]
|
||||
Web["webAppService.ts<br/>(browser / web build)"]
|
||||
Node["nodeAppService.ts<br/>(Node tooling, tests, CLI)"]
|
||||
|
||||
Caller --> AppSvc
|
||||
AppSvc --> Native
|
||||
AppSvc --> Web
|
||||
AppSvc --> Node
|
||||
|
||||
Native -- "@tauri-apps/api invoke()" --> Rust["src-tauri Rust commands"]
|
||||
Native --> Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, native-bridge, native-tts, turso)"]
|
||||
Web --> Browser["browser APIs (File, IndexedDB, fetch)"]
|
||||
Web --> RemoteAPI["fetch() to /api/*"]
|
||||
Node --> Fs["node:fs, node:path"]
|
||||
```
|
||||
|
||||
`environment.ts` decides at runtime which implementation to mount, based on the
|
||||
build target (`NEXT_PUBLIC_APP_PLATFORM`) and runtime detection (`window`,
|
||||
Tauri injection). Most callers in the codebase do
|
||||
`const appService = useEnv().appService` and never know which one they got.
|
||||
|
||||
The same pattern repeats for the database layer in `src/services/database`:
|
||||
`webDatabaseService` (browser via Turso WASM), `nativeDatabaseService` (Tauri
|
||||
via the `tauri-plugin-turso` plugin), and `nodeDatabaseService` (Node, used by
|
||||
tests). All three share `migrate.ts` and `migrations/*`.
|
||||
|
||||
This is why most domain code in `src/services` looks platform-agnostic — the
|
||||
platform difference has been pushed to a small number of seams.
|
||||
|
||||
## 5. Backend (Next.js routes)
|
||||
|
||||
There are two route trees because of historical mix between App Router and
|
||||
Pages Router. The split is pragmatic, not load-bearing: new routes go to
|
||||
`src/app/api`, legacy/sync/storage live in `src/pages/api`.
|
||||
|
||||
### 5.1 Pages Router endpoints (`src/pages/api`)
|
||||
|
||||
These are the long-standing server endpoints around sync, storage, and email:
|
||||
|
||||
```
|
||||
sync.ts -> KOReader-compatible sync client (`KOSyncClient`)
|
||||
kosync.ts -> KOSync legacy bridge
|
||||
sync/replicas.ts -> replica sync upload/download (encrypted blobs)
|
||||
sync/replica-keys.ts -> replica key bootstrap
|
||||
storage/upload.ts -> presigned upload to S3/R2 for book bytes
|
||||
storage/download.ts -> presigned download
|
||||
storage/list.ts -> list user's objects
|
||||
storage/delete.ts -> delete a single object
|
||||
storage/purge.ts -> bulk wipe (account deletion path)
|
||||
storage/stats.ts -> per-user usage/quotas
|
||||
send/inbox.ts -> "Send to Readest" inbox listing
|
||||
send/inbox/* -> inbox item operations
|
||||
send/address.ts -> per-user inbox address resolver
|
||||
send/fetch-url.ts -> server-side URL fetcher for "send a link"
|
||||
send/senders.ts -> sender allowlist
|
||||
deepl/translate.ts -> DeepL translation proxy (hides API key)
|
||||
user/delete.ts -> account deletion
|
||||
```
|
||||
|
||||
The storage layer talks to S3-compatible storage through `src/utils/s3.ts`,
|
||||
which honors a `S3_PUBLIC_ENDPOINT` distinct from the internal endpoint so
|
||||
docker-compose deployments can route browsers through one origin and the
|
||||
server through another.
|
||||
|
||||
### 5.2 App Router endpoints (`src/app/api`)
|
||||
|
||||
Newer endpoints, grouped by domain:
|
||||
|
||||
```
|
||||
ai/chat -> streaming AI chat (Vercel AI SDK)
|
||||
ai/embed -> embeddings for in-book RAG
|
||||
metadata/search -> metadata lookup (Google Books / Open Library)
|
||||
opds/proxy -> CORS-friendly OPDS proxy
|
||||
tts/edge -> Edge TTS streaming
|
||||
hardcover/graphql -> Hardcover GraphQL relay
|
||||
stripe/checkout -> create checkout session
|
||||
stripe/portal -> billing portal redirect
|
||||
stripe/plans -> plan listing
|
||||
stripe/check -> subscription state
|
||||
stripe/webhook -> Stripe webhook handler
|
||||
google/iap-verify -> Google Play IAP verification
|
||||
apple/iap-verify -> App Store IAP verification
|
||||
share/* -> share-link landing + read-only render
|
||||
```
|
||||
|
||||
### 5.3 Workers
|
||||
|
||||
`apps/readest-app/workers/send-email` is a separate Cloudflare Worker
|
||||
(deployed independently from the main app) responsible for the "Send to Readest
|
||||
by email" path. It receives mail, normalizes attachments, and drops items into
|
||||
the user's inbox so that the in-app `Send` page can pick them up via the
|
||||
`/api/send/inbox` endpoints.
|
||||
|
||||
### 5.4 Runtime config
|
||||
|
||||
`src/app/runtime-config.js/route.ts` is a server route that builds a small JSON
|
||||
object — `supabaseUrl`, `supabaseAnonKey`, `apiBaseUrl`, `objectStorageType`,
|
||||
`storageFixedQuota`, `translationFixedQuota` — from `process.env` at request
|
||||
time and serializes it as a JS payload. The client reads it through
|
||||
`getRuntimeConfig()` in `src/services/runtimeConfig.ts` (browser) or
|
||||
`getServerRuntimeConfig()` (server). This is the mechanism that makes the same
|
||||
prebuilt Docker image rebrandable per deployment.
|
||||
|
||||
## 6. Cross-cutting subsystems
|
||||
|
||||
These don't live in one file or one route; they span the frontend, the backend,
|
||||
and (sometimes) the native shell.
|
||||
|
||||
### 6.1 Sync
|
||||
|
||||
Two sync paths coexist:
|
||||
|
||||
The first is **legacy KOReader-compatible sync** for reading progress,
|
||||
implemented by `src/services/sync/KOSyncClient.ts` against `pages/api/sync.ts`
|
||||
and `pages/api/kosync.ts`. It exists for compatibility with KOSync-style
|
||||
clients.
|
||||
|
||||
The second is **replica sync**, the modern path. It encrypts each replica
|
||||
locally with a passphrase-derived key
|
||||
(`replicaCryptoMiddleware.ts`, `passphraseGate.ts`), publishes deltas to
|
||||
`pages/api/sync/replicas.ts`, pulls peer updates, and applies them through
|
||||
category adapters in `src/services/sync/adapters/*` (annotations, settings,
|
||||
dictionaries, fonts, textures, OPDS catalogs). The orchestrator is
|
||||
`replicaSyncManager.ts`. A cursor store (`replicaCursorStore.ts`) tracks "where
|
||||
I last pulled to" per category so syncs are incremental.
|
||||
|
||||
### 6.2 Cloud library
|
||||
|
||||
Distinct from replica sync. The cloud library handles **book bytes** (not
|
||||
metadata):
|
||||
|
||||
- import flow: `src/services/ingestService.ts` decides whether a book is
|
||||
imported as a hash copy under `Books/<hash>/` or kept *in place* at the
|
||||
user's chosen path (the "in-place" mode added in commit `dd107277`).
|
||||
- upload: `cloudService.uploadBook` uses the storage layer to push bytes to S3
|
||||
through `pages/api/storage/upload.ts`.
|
||||
- download: peers fetch via `pages/api/storage/download.ts`, materializing the
|
||||
book into `Books/<hash>/` regardless of whether the original device kept it
|
||||
in-place.
|
||||
- delete: symmetric local/cloud/both semantics in `cloudService.deleteBook`.
|
||||
|
||||
### 6.3 AI / RAG
|
||||
|
||||
`src/services/ai` provides the chat and embedding abstraction with provider
|
||||
adapters, prompt assembly, chunking, retry, and a local AI store. UI lives in
|
||||
`components/assistant` and the reader-side `app/reader/components/AIChat*`. The
|
||||
HTTP entrypoints are `src/app/api/ai/chat` (streaming) and
|
||||
`src/app/api/ai/embed`. The reader can do book-scoped RAG by embedding chapters
|
||||
locally and querying the embeddings store.
|
||||
|
||||
### 6.4 Translation
|
||||
|
||||
`src/services/translators` has provider adapters for DeepL, Google, Azure, and
|
||||
Yandex, plus a preprocess + cache + polish pipeline. DeepL goes through a
|
||||
server proxy (`pages/api/deepl/translate.ts`) to keep the API key server-side;
|
||||
the others can hit the providers directly from the client.
|
||||
|
||||
### 6.5 TTS
|
||||
|
||||
Three TTS backends behind one interface (`src/services/tts`):
|
||||
|
||||
- `WebSpeechClient` for browsers,
|
||||
- `NativeTTSClient` for Tauri via `tauri-plugin-native-tts`,
|
||||
- `EdgeTTSClient` going through `src/app/api/tts/edge` for streaming Microsoft
|
||||
Edge voices.
|
||||
|
||||
### 6.6 Dictionaries
|
||||
|
||||
`src/services/dictionaries` parses StarDict and SLOB packs locally
|
||||
(`readers/`), and integrates online sources (Wikipedia, Wiktionary,
|
||||
provider-specific). Lookup goes through a candidate generator + dedup so
|
||||
clicking a word finds all installed dictionaries and online sources in one
|
||||
roundtrip.
|
||||
|
||||
### 6.7 OPDS / Calibre
|
||||
|
||||
`src/services/opds` parses feeds, supports auto-download, and tracks
|
||||
subscription state. Cross-origin feeds are tunneled through
|
||||
`src/app/api/opds/proxy`. The library UI surfaces OPDS shelves alongside local
|
||||
books.
|
||||
|
||||
### 6.8 Third-party reading services
|
||||
|
||||
Hardcover (`src/services/hardcover` + `src/app/api/hardcover/graphql`) and
|
||||
Readwise (`src/services/readwise`) integrations let users export reading
|
||||
progress and highlights.
|
||||
|
||||
### 6.9 Annotations
|
||||
|
||||
`src/services/annotation` defines the canonical annotation model and provides
|
||||
adapters: a Foliate adapter (the default in-app representation) and an MR
|
||||
import/export adapter for moving annotations to and from MoonReader.
|
||||
|
||||
### 6.10 RSVP and content transforms
|
||||
|
||||
`src/services/rsvp` is the rapid-serial-visual-presentation reading mode.
|
||||
`src/services/transformers` contains pure functions for language detection,
|
||||
punctuation normalization, whitespace collapsing, proofread suggestions,
|
||||
sanitization, footnote rewriting, style injection, traditional/simplified
|
||||
Chinese conversion (via `simplecc-wasm`), and Warichu (Japanese ruby/rubi)
|
||||
layout. These are reused by the reader, by RSVP, and by the
|
||||
"Send to Readest" article-to-EPUB conversion.
|
||||
|
||||
### 6.11 Send to Readest
|
||||
|
||||
End-to-end pipeline:
|
||||
|
||||
1. The browser extension (`apps/readest-app/extension/send-to-readest`) or the
|
||||
email-to-inbox path (`workers/send-email`) submits a URL or article HTML.
|
||||
2. `src/services/send/conversion/*` sanitizes the content and converts it to
|
||||
EPUB (sanitization, TOC building, asset bundling, worker protocol).
|
||||
3. The result lands in the user's inbox served by `pages/api/send/inbox*`.
|
||||
4. The `app/send` page or the in-app inbox drainer
|
||||
(`src/services/send/inboxDrainer.ts`) imports it into the library through
|
||||
the standard ingest service.
|
||||
|
||||
## 7. Native shell (`src-tauri`)
|
||||
|
||||
The Tauri host is shared by desktop and mobile. The Rust side (`src-tauri/src`)
|
||||
is small and focused:
|
||||
|
||||
```
|
||||
lib.rs -> command registration, scope grants, deep links, builder
|
||||
main.rs -> entrypoint
|
||||
clip_url.rs -> clipboard URL extraction
|
||||
dir_scanner.rs -> recursive directory scan (used by library import)
|
||||
transfer_file.rs -> chunked upload/download for big files
|
||||
discord_rpc.rs -> Discord Rich Presence (desktop only)
|
||||
android/, macos/,
|
||||
windows/ -> per-platform glue
|
||||
```
|
||||
|
||||
Everything else is delegated to **Tauri plugins**, mostly bundled in
|
||||
`packages/tauri-plugins/plugins`:
|
||||
|
||||
- standard plugins: `fs`, `dialog`, `http`, `opener`, `os`, `process`, `shell`,
|
||||
`cli`, `deep-link`, `haptics`, `log`, `updater`, `websocket`, `oauth`,
|
||||
`persisted-scope`, `device-info`, `sharekit`
|
||||
- in-tree custom plugins:
|
||||
- `tauri-plugin-native-bridge` — Android-side bridges (directory picker
|
||||
callback, open external URL, etc.)
|
||||
- `tauri-plugin-native-tts` — native text-to-speech
|
||||
- `tauri-plugin-turso` — embedded Turso/libSQL database for the native
|
||||
targets, mirrored by the WASM build used in the browser
|
||||
- `tauri-plugin-webview-upgrade` — webview update flow on platforms where
|
||||
that matters
|
||||
|
||||
A subtle but important detail in `lib.rs`: `allow_paths_in_scopes` is the
|
||||
frontend-callable shim that extends both `fs_scope` and `asset_protocol_scope`
|
||||
**only for paths the Tauri dialog plugin (or persisted-scope on restart)
|
||||
already granted**. Without that gate, any frontend code path — including a
|
||||
hypothetical XSS through book content, OPDS HTML, or a compromised dependency —
|
||||
could grant itself read access to the user's home directory through the asset
|
||||
protocol. The gate constrains the command to user-picked paths only.
|
||||
|
||||
## 8. Build and deploy
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Source["apps/readest-app (single source)"]
|
||||
|
||||
subgraph BuildTargets
|
||||
BWeb["next build<br/>+ @opennextjs/cloudflare<br/>(.env.web)"]
|
||||
BTauriDesk["next build → tauri build<br/>(.env.tauri)"]
|
||||
BTauriMob["next build → tauri android/ios build"]
|
||||
end
|
||||
|
||||
subgraph DeployTargets
|
||||
DCloudflare["Cloudflare Workers<br/>(web.readest.com)"]
|
||||
DDocker["Docker image<br/>(ghcr.io/readest/readest)"]
|
||||
DDesktop["dmg / nsis / appimage"]
|
||||
DMobile["aab / ipa"]
|
||||
DExt["browser extension package"]
|
||||
end
|
||||
|
||||
Source --> BWeb
|
||||
Source --> BTauriDesk
|
||||
Source --> BTauriMob
|
||||
|
||||
BWeb --> DCloudflare
|
||||
BWeb --> DDocker
|
||||
BTauriDesk --> DDesktop
|
||||
BTauriMob --> DMobile
|
||||
Source --> DExt
|
||||
```
|
||||
|
||||
The web target has two delivery modes: a Cloudflare Worker via OpenNext
|
||||
(`pnpm deploy`) and a self-hostable Docker image built and published from
|
||||
`.github/workflows/docker-image.yml` to GHCR and Docker Hub. The Docker image
|
||||
uses `docker/compose.yaml` (pull) plus `docker/compose.build.yaml` (build) and
|
||||
relies on the runtime-config mechanism described in section 5.4 so a single
|
||||
prebuilt image can be parameterized with `.env`.
|
||||
|
||||
Tauri builds use `dotenv` to switch env files (`.env.tauri`,
|
||||
`.env.tauri.local`, `.env.apple-*.local`, `.env.ios-*.local`,
|
||||
`.env.google-play.local`) for code-signing and store-specific configuration.
|
||||
Mobile and desktop produce installable bundles (dmg, nsis, appimage, aab, ipa).
|
||||
|
||||
## 9. Quick rule of thumb
|
||||
|
||||
When trying to place a piece of behavior, ask in this order:
|
||||
|
||||
Does it talk to a remote service or write to durable shared storage? Then it
|
||||
ends up in `src/pages/api` or `src/app/api`, possibly fronted by a service
|
||||
under `src/services`. Does it touch the user's filesystem, native dialogs, the
|
||||
shell, or system TTS? Then it goes through `appService` and lands in
|
||||
`nativeAppService` (Tauri commands in `src-tauri/src/lib.rs`) or
|
||||
`webAppService` (browser equivalent). Does it manipulate book content, render
|
||||
the reader, or maintain UI state? Then it lives under `src/app/reader`,
|
||||
`src/components`, `src/hooks`, `src/store`, or one of the reader-side service
|
||||
folders (`annotation`, `nav`, `rsvp`, `transformers`, `dictionaries`,
|
||||
`translators`, `tts`). Is it a sync or cloud-library concern? `src/services/sync`
|
||||
plus the matching API route, or `src/services/cloudService.ts` plus
|
||||
`pages/api/storage/*`.
|
||||
|
||||
If you can answer "which runtime owns this" in one sentence, you've placed the
|
||||
file correctly. If you can't, it's probably shared and belongs under
|
||||
`src/services`, `src/utils`, `src/libs`, or `src/types`.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Book Config JSON
|
||||
|
||||
Each imported book may have a per-book config file at:
|
||||
|
||||
```text
|
||||
<bookHash>/config.json
|
||||
```
|
||||
|
||||
The file is written under the `Books` storage root and uses the same camelCase
|
||||
keys as the TypeScript `BookConfig` type in `src/types/book.ts`.
|
||||
|
||||
## Version
|
||||
|
||||
`schemaVersion` identifies the raw `config.json` schema written by Readest.
|
||||
Current version:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1
|
||||
}
|
||||
```
|
||||
|
||||
Configs written before this field existed are treated as legacy configs and are
|
||||
loaded as the current version. New writes must include `schemaVersion`.
|
||||
|
||||
`schemaVersion` is only for the raw disk JSON file. The cloud sync
|
||||
`book_configs` table is a normalized sync projection and does not mirror this
|
||||
field.
|
||||
|
||||
## Stable Fields
|
||||
|
||||
Version 1 documents these fields as the supported integration surface:
|
||||
|
||||
- `schemaVersion`: raw config schema version.
|
||||
- `bookHash`, `metaHash`: book identity when present.
|
||||
- `progress`: current page tuple, `[current, total]`.
|
||||
- `location`: current reading location as CFI.
|
||||
- `xpointer`: current reading location as XPointer for KOReader interoperability.
|
||||
- `booknotes`: bookmarks, annotations, and excerpts.
|
||||
- `rsvpPosition`: RSVP reading position.
|
||||
- `updatedAt`: last config update timestamp in milliseconds.
|
||||
|
||||
`viewSettings` and `searchConfig` are persisted app state. They are partial
|
||||
overrides and are merged with defaults when Readest loads the config.
|
||||
|
||||
## Notes and XPointer Fields
|
||||
|
||||
`BookConfig.xpointer` is the current reading location. It was not renamed by
|
||||
KOReader annotation sync work.
|
||||
|
||||
For notes in `booknotes`, Readest stores note ranges with:
|
||||
|
||||
- `xpointer0`: start XPointer.
|
||||
- `xpointer1`: end XPointer, when available.
|
||||
|
||||
This distinction matters for integrations reading raw config files: progress
|
||||
uses `xpointer`, while note ranges use `xpointer0` and `xpointer1`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user