Compare commits

...

180 Commits

Author SHA1 Message Date
Huang Xin 27fa9ab226 chore(i18n): translate new strings; commit pending agent memory notes (#4430)
Translate 4 new keys across all 33 locales:
- Send
- Book file is not available locally
- Failed to send book
- Highlight Current Sentence

Also commit pending .claude/memory bug-fix notes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:01:47 +02:00
Huang Xin c2bbb6119a fix(reader): keep paginated page background inside its column (#4394) (#4429)
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:48:56 +02:00
Huang Xin 578b7ba14f fix(reader): restore annotation list auto-scroll to the nearest item (#4428)
Virtualizing BooknoteView (#4352) dropped the auto-scroll that centers the
note nearest the current reading position, leaving the list stranded at the
top. The single scrollToIndex that replaced the per-item useScrollToItem was
missing the machinery TOCView already uses for virtualized auto-scroll:

- Re-apply the scroll inside the OverlayScrollbars `initialized` callback
  (read via a ref): its deferred init resets the viewport scrollTop to 0, and
  the lastScrolledCfiRef guard otherwise blocked any retry (reload case).
- Mount Virtuoso natively centered via initialTopMostItemIndex with a
  skip-gate, so opening the panel while reading doesn't fire a scrollToIndex
  that races and wedges the freshly mounted, unmeasured list (tab-switch case).
- Jump instantly (behavior 'auto') for far moves and on eink, animating
  'smooth' only for short in-session updates — mirroring TOCView.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:39:45 +02:00
Huang Xin 9d8062ae27 fix(reader): keep table background matching the page in dark mode (#4419) (#4426)
The dark-mode `table *` color-mix tint in getColorStyles was applied
unconditionally since #4055, so plain tables — and the invisible spacer
cells some books use for vertical TOC layout — rendered a few shades off
the page background, and the spacing between words appeared to change.

Restore the `overrideColor` gate that #2377 originally added. Illegible
light/zebra table backgrounds (the #4028 case #4055 targeted) are now
handled separately by the dark-mode light-background rewriters from
#4392, so the blanket tint is no longer needed by default. The
standalone blockquote tint stays unconditional in dark mode.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:20:59 +02:00
Huang Xin 7128e8964e fix(reader): suppress Android image callout freezing the image viewer (#4425)
Long-pressing an image in the zoom viewer on Android triggers the
WebView's native image callout (context menu / drag / magnifier) at the
same time as the viewer's own pinch/pan/zoom touch handlers, locking up
the whole app until restart. Same root cause as the book-cover freeze
(PR #4345): `-webkit-touch-callout: none` doesn't inherit, so the class
must sit on an ancestor of the `<img>`.

Apply the existing `.no-context-menu` class to the viewer container so
the `.no-context-menu img` rule reaches the zoomed image and disables
the native callout. Harmless on desktop (the property is a no-op there
and right-click-save still works).

Closes #4420

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:04:52 +02:00
Huang Xin e4bb9fc4b7 refactor(share): make saveFile content nullable for path-based shares (#4424)
The book "Send" flow had to pass `new ArrayBuffer(0)` to saveFile purely to
satisfy the type-checker: the content arg is ignored on the native share
path when `options.filePath` points at an already-on-disk file. Widen
saveFile's content parameter to `string | ArrayBuffer | null` across the
AppService contract so callers can hand off a file by path without buffering
it into memory, and pass `null` from the Send flow instead of a throwaway
empty buffer.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:49:47 +02:00
Huang Xin df66c63a07 fix(txt): recover author for 【】-titled web-novel TXT imports, closes #4390 (#4423)
Chinese web-novel TXT files are commonly named 【书名】1-129 作者:起落.txt and
carry a noisy metadata block at the top of the file. Two author-recognition
failures resulted after importing them:

  1. Author missing — extractTxtFilenameMetadata only pulled an author from
     《》-wrapped names, so 【】-style names yielded no filename author, and when
     the file header had no clean "作者:X" line the author came out empty.

  2. Irrelevant content as author — the greedy file-header capture
     (/作者…(.+)\r?\n/) grabbed a publication blob like
     "2024/08/01发表于:是否首发:是 字数1023150字…" and surfaced it as the author.

Fix:
  - extractTxtFilenameMetadata now extracts the labeled "作者:X" form from any
    filename (title stays the full name; only the labeled form is safe so a
    leading 【title】 isn't mistaken for the author).
  - Validate the header-matched author (isPlausibleAuthorName) and fall back to
    the filename author when it looks like a metadata blob — embedded field
    separator, long digit run, or excessive length. Applied to both the small-
    and large-file conversion paths.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:44:07 +02:00
Huang Xin 963bab0f0f fix(library): stop bookshelf context menu shuffling its order (#4389) (#4421)
The bookshelf right-click menu built itself with un-awaited
`Menu.append()` calls. Each append is an async IPC round-trip to the
Tauri backend, so the concurrent fire-and-forget requests resolved in
non-deterministic order on the Rust side and the native menu items
landed shuffled on every open (only reproducible on the native app,
invisible in jsdom).

Build the items in order and create the menu in a single
`await Menu.new({ items })` call for both the book and group handlers.
Order and conditional inclusion are unchanged.

Extract the order/inclusion logic into a pure `getBookContextMenuItemIds`
helper in `libraryUtils.ts` so the deterministic ordering is unit-tested
without mounting the component or mocking Tauri.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:40:11 +02:00
Huang Xin f9ddddb6ac fix(library): use ghost cancel buttons in migrate-data dialog for e-ink (#4396) (#4422)
The Change Data Location dialog rendered its Cancel/Close (secondary)
buttons with `btn-outline`. Under `[data-eink='true']`, globals.css
inverts both `.btn-outline` and `.btn-primary` to the same base-content
fill + base-100 text, so Cancel and Start Migration collapsed into two
identical black buttons and became indistinguishable on e-ink screens.

Switch the secondary buttons to `btn-ghost`, matching the design-system
rule (DESIGN.md): the primary CTA keeps its solid fill while the ghost
cancel reads as borderless next to it, restoring the hierarchy.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:36:57 +02:00
loveheaven fe853554a9 feat(library): send book file from bookshelf selection popup (#4402)
* feat(library): send book file from bookshelf selection popup

Adds a Send button to the bottom popup that appears when one or more
books are selected on the bookshelf. Hands the actual book file
(epub/pdf/...) to the OS share sheet via tauri-plugin-sharekit
(UIActivityViewController on iOS, Intent.ACTION_SEND on Android,
NSSharingServicePicker on macOS), so users can fire the file off to
Mail / Messages / WeChat / AirDrop / etc.

This is intentionally distinct from the per-item context-menu "Share
Book", which uploads the book to the readest backend and generates a
public link. "Send" is offline file egress; "Share Book" is remote
collaboration. They share zero infra.

Resolution rules mirror bookContent.resolveBookContentSource: managed
copy under Books/<hash>/ first, then the device-local in-place import
path. Cloud-only books warn rather than silently no-op.

Path is handed to shareFile via options.filePath. Without that,
saveFile() falls back to writing a temp copy under BaseDirectory.Temp,
which on Android resolves to /data/local/tmp/ — the app sandbox has
no write permission there and the call fails with EACCES ("failed to
open file at path: /data/local/tmp/...epub Permission denied (os error
13)"). Passing the absolute path also avoids re-buffering the entire
epub/pdf into memory.

On macOS the NSSharingServicePicker is anchored to the selected
book's cover rather than to the Send button — the user's visual
focus is on the cover they just tapped, not on the bottom toolbar.
BookshelfItem stamps a data-book-hash attribute on its root div so
the Send handler can locate the cell via querySelector and pass its
rect through saveFile's sharePosition option. preferredEdge='bottom'
maps to NSMinYEdge, so the popover renders above the cover (and only
auto-flips below when there's no room above). iOS / Android share
sheets are modal and ignore sharePosition, so the same code is a
no-op there.

The button is hidden on Linux (no system share sheet), Windows
(WebView2 share UI deadlocks the main thread, see #4343), and web
browsers (no "send file to <app>" affordance for arbitrary downloads).

* fix(share): don't fall back to saveDialog when shareFile is cancelled

The native saveFile({ share: true }) path used to swallow any error
from sharekit's shareFile() and fall through to saveDialog. The plugin
treats user cancellation the same as a failure (it rejects with
'Share cancelled' on Android when the user dismisses the share sheet),
so cancelling a share popped up an unwanted 'Save As...' dialog right
after the user explicitly chose not to share.

Mirror what webAppService already does for the navigator.share()
AbortError path: once we entered the share branch, return true
regardless of whether the share completed or was cancelled. The
saveDialog path is now reserved for Linux/Windows desktop, which never
hit the share branch in the first place (wantShare gates them out).

If a future caller wants 'try share, fall back to save on hard
failure', that decision belongs to the caller — saveFile shouldn't
silently override an explicit share intent.
2026-06-02 16:09:29 +02:00
Huang Xin 4abbc0254c fix(reader): stop footer progress info painting a stray focus ring (#4397) (#4418)
The always-on page-info footer (`.progressinfo`) is a decorative
`role='presentation'` element, but it carried `tabIndex={-1}`, which made
it focusable. On Android, long-pressing the footer focused the div and the
WebView painted its default focus ring (`outline: auto`). Because the
element is pinned `absolute bottom-0` at book-view width, the ring rendered
as a content-column-wide line across the bottom of every page and persisted
until focus cleared.

Remove the `tabIndex` so the decorative element can no longer receive
focus. The `onClick` (tap-to-cycle progress mode / dismiss popup) still
fires regardless of tabindex, nothing focuses it programmatically, and the
translated `aria-label` keeps it exposed to screen readers unchanged.

Closes #4397

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:08:41 +02:00
loveheaven 7283a8ac21 fix(android): open book directly when launched via 'Open with' (#4407)
On Android, tapping an EPUB/MOBI/AZW3 in the system file browser and choosing Readest only opens the library — the book itself never opens in the reader. Now Readest can open the book now.
2026-06-02 16:07:23 +02:00
loveheaven 726f53a64b fix(reader): use Tauri clipboard plugin for copy on Android (#4409)
navigator.clipboard.writeText is unreliable inside the Tauri Android
WebView, so tapping Copy in the Reader's selection popup silently
no-ops. Route the write through @tauri-apps/plugin-clipboard-manager
on Tauri targets (Android, iOS, macOS, Windows, Linux), with a
graceful navigator.clipboard / execCommand fallback for the web
build and older WebViews.

- Add tauri-plugin-clipboard-manager (Rust + JS)
- Register the plugin in lib.rs
- Grant clipboard-manager:allow-write-text / allow-read-text
- New utils/clipboard.ts wrapper with platform-aware fallback chain
- Annotator handleCopy and handleConfirmExport use the wrapper
2026-06-02 16:02:52 +02:00
wfjack 5ff18b8f32 fix(readwise): use Tauri HTTP transport in desktop app (#4413)
Switch ReadwiseClient to @tauri-apps/plugin-http when running in
Tauri desktop mode, matching the pattern already used by WebDAV,
Hardcover, AI providers, translators, OPDS, and KOReader sync.

In a Tauri webview context the standard window.fetch is still subject
to CORS preflight rules and—on Android—the platform's cleartext-traffic
policy. @tauri-apps/plugin-http sends the request from the Rust side
via reqwest, bypassing the renderer entirely (no Origin header, no
preflight, no cleartext block). ReadwiseClient was one of the few
remaining services that had not yet adopted this transport, so users
on the desktop build could hit CORS or network errors when validating
tokens or pushing highlights.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-02 15:59:17 +02:00
Huang Xin 274afb0677 fix(sync): mint reincarnation token on re-import of custom fonts/textures (#4410) (#4416)
Custom fonts (and textures) disappeared a few seconds after opening a book
when logged into cloud sync. Under the replica layer's CRDT remove-wins
semantics, deleting a font writes a server-side tombstone that a plain
field upsert cannot revive — only a reincarnation token whose HLC beats
the tombstone does. Re-uploading the same file (same contentId) cleared
`deletedAt` locally but published the upsert with no token, so the tombstone
survived and the next pull (boot/periodic/book-open/visibility) re-applied
the soft-delete via softDeleteByContentId, making the font vanish. Logging
out stopped the pull, which is why it only reproduced while signed in.

addFont/addTexture now mint a reincarnation token when re-adding an existing
entry that is either soft-deleted or still-live with the same contentId (and
has no token yet), preserving any existing token. This mirrors the
dictionary's reincarnation handling (dictionaryService) and OPDS's token
style, fixing both the local re-import-after-delete case and the multi-device
stale-local race. The token is inert when there is no tombstone, so live
re-imports remain safe.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:56:38 +02:00
Huang Xin fe7fe25482 fix(reader): show background texture in paginated mode (#4399) (#4417)
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 15:54:59 +02:00
Huang Xin 3a81e09911 fix(reader): scroll oversized blocks in-place instead of turning the page (#4400) (#4415)
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).

- Wrap tables and display equations in a horizontally/vertically scrollable
  container; route touch + wheel along the box's scrollable axis so it scrolls
  the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
  or captures gestures; the fit decision is measured once after layout via a
  self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
  to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
  cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
  so existing highlights, bookmarks and KOSync positions inside a wrapped table
  or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:45:18 +02:00
Jesus Eduardo Medina Gallo 176b950c92 fix(reader): replace light callout backgrounds in dark mode (#4392)
Why:
- Dark mode sets theme foreground on html/body and rewrites black text,
  but EPUB callout boxes often keep white/light backgrounds from inline
  styles or publisher CSS unless override book color is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:16:52 +02:00
Jesus Eduardo Medina Gallo 458ad7510c fix(reader): scroll wide EPUB tables horizontally (#4391)
* fix(reader): scroll wide tables horizontally instead of scaling

Why:
- Wide EPUB tables (e.g. many columns without cell widths) overflowed the
  page because CSS scale only applied when widths were known.
- Paginated mode stole horizontal swipes for page turns over table content.

Refs:
- Replaces transform-based applyTableStyle scaling with a scroll wrapper
  and capture-phase touch routing (same pattern as gesture brightness).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(reader): keep wheel/trackpad table scrolling from turning the page

Horizontal scrolling of a wide table in paginated mode also turned the page:

- applyTableTouchScroll only routed touch events. Trackpad/mouse wheel
  (readest forwards iframe wheel -> 'iframe-wheel' -> pagination) was
  never intercepted, so a horizontal wheel both scrolled the table and
  flipped the page.
- findWrapper used `instanceof Element`, which is always false for iframe
  event targets because this module runs in the top-window realm. The
  touch routing therefore never fired either.

Add a capture-phase wheel handler that consumes horizontal wheels over a
scrollable table -- including at the scroll edge, so the gesture (and
trackpad momentum) never chains into a page turn -- and make findWrapper
cross-realm safe via duck-typing on `closest`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): don't show a spurious scrollbar on layout tables

Wrapping every table for horizontal scrolling made tables that fit the
column (e.g. character/glossary layout tables) show a spurious horizontal
scrollbar, because the wrapper was always scrollable.

Now the wrapper clips (no scrollbar) for a table that fits within a few px
of the column, and a ResizeObserver re-evaluates this as the column width
settles. A table genuinely wider than the column always scrolls and is
never clipped; one that wraps to fit shows no scrollbar. Touch/wheel
routing engages only scrollable wrappers.

Add a Chromium browser test over sample-table-layout.epub (layout tables
must not scroll) and sample-table-wide.epub (a too-wide table must scroll,
not clip).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:42:53 +02:00
Huang Xin bc9fe67abf fix(desktop): sanitize invalid .window-state.json before restore (#4401)
A `.window-state.json` containing the Windows minimized sentinel
(x/y = -32000) or a 0×0 size makes WebView2 reject the restored bounds
with 0x80070057 ("The parameter is incorrect"), so the app fails to
launch until the file is deleted by hand.

Add a small `window-state-sanitizer` plugin, registered before
tauri-plugin-window-state, that strips window entries with invalid
geometry (non-positive size, or a position past the -16000 off-screen
cutoff) from the state file before the plugin loads it. Affected windows
fall back to default geometry instead of crashing.

Defense-in-depth: the bundled plugin (2.4.1) already guards against
writing these values, so a bad file is almost certainly stale from an
older build; this self-heals it on next launch.

Refs #4398

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:01:20 +02:00
Huang Xin 45ef5f7515 fix(metainfo): declare desktop and mobile device support (#4395)
The AppStream metainfo had no <requires>/<supports> relations, so software
stores such as Flathub listed Readest as "Desktop only". Readest is adaptive
and runs on desktop (keyboard/mouse) as well as phones and tablets (touch).

Declare a 360px display_length baseline plus keyboard, pointing and touch
controls so stores surface both Desktop and Mobile.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 05:13:45 +02:00
Huang Xin 97191a57c0 fix(reader): stop reading ruler creeping down on scroll (#4386) (#4388)
In scrolled mode the ruler's geometry-cache effect re-ran on every
relocate — including those fired continuously while scrolling — and
re-snapped the band to the next line forward from a screen-fixed
anchor, so the band crept down the page as the reader scrolled.

Place the band once on mount (and after a viewport-dimension change),
but never re-snap on a plain scroll relocate. Click-driven snapping
(the reading-ruler-move handler + pendingScrollAlign realign) is
unchanged, and paginated mode is unaffected.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 18:25:44 +02:00
Huang Xin a92fe0ce2e fix(koplugin): upload local book cover so synced books show a cover (#4374) (#4385)
uploadBook only attached a cover.png when one was already cached under
readest_covers/<hash>.png from a prior cloud download. Books that
originated locally in KOReader were never downloaded, so the cover step
was silently skipped and they synced to Readest with no cover.

Add extractLocalCover, which renders the book's embedded cover via
coverbrowser's FileManagerBookInfo:getCoverImage(nil, file_path) and
writes it as PNG. uploadBook now falls back to it when no cached cover
exists, caching the result under covers_dir so the Library view reuses
it like a downloaded cover.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:36:23 +02:00
loveheaven e8675fb7eb fix(reader): inline custom @font-face rules in iframe stylesheet (#4383)
* fix(reader): inline custom @font-face rules in iframe stylesheet

The reader iframe's first paint resolved with the default serif/sans
fallback and only swapped to the user's configured custom font a moment
later, producing a visible font flash when opening a book.

Custom font @font-face rules were registered via mountCustomFont on the
host document only, while paginator's setStyles writes its CSS into
the iframe synchronously before the first 'load' event. The iframe had
no knowledge of the user's custom fonts at that point, so font-family
declarations in the stylesheet matched nothing and fell back.

Inline the @font-face rules for every loaded custom font (blob URLs
already in memory, no network round-trip) at the front of getStyles
output, so paginator delivers them to the iframe atomically with the
rest of the stylesheet. Defensive try/catch around createFontCSS keeps
a single bad font from breaking the whole stylesheet.

* refactor(reader): pass custom fonts into getStyles instead of reading the store

getStyles lives in src/utils, where every other file is a pure function;
it was the only one importing a store (useCustomFontStore). Keep the
util pure: accept the loaded custom fonts as a parameter and let the
reader components — which already own the font store — supply them.

- style.ts drops the useCustomFontStore import and the SSR/store-error
  guards in getCustomFontFaces; the helper is now a pure CustomFont[] ->
  CSS transform.
- getStyles(viewSettings, themeCode?, customFonts = []) inlines the
  @font-face rules for the passed fonts.
- The first-paint call sites (FoliateViewer, FootnotePopup) pass
  getLoadedFonts(); settings-panel re-styles keep the default [] since
  custom fonts are already mounted as persistent <style> elements there.
- Add tests that exercise the font-face inlining path (the existing
  suite never did, since the store is empty under jsdom).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 17:24:47 +02:00
Huang Xin 9b4db44490 fix(pdf): ship jbig2.wasm so scanned PDFs render in packaged builds (#4382)
pdfjs-dist 5.7.x (bumped 5.4.530 -> 5.7.284 in #4143) moved the JBIG2
image decoder -- the codec used by virtually every black-and-white
*scanned* PDF -- from pure JS into a WebAssembly module the worker
fetches at runtime from `wasmUrl` (/vendor/pdfjs/). The `copy-pdfjs-wasm`
script only copied an explicit allow-list ({openjpeg.wasm,qcms_bg.wasm})
and silently dropped jbig2.wasm. cpx does not error on an empty glob, so
the missing decoder went unnoticed: scanned PDFs rendered blank (pages
still turned) in CI builds, while local `tauri build` reused a stale
pre-bump public/vendor copy and worked. Regression between 0.11.1 and
0.11.2.

Copy the whole wasm/ dir (mirrors the {cmaps,standard_fonts}/* fonts
pattern) so future decoders moving to wasm can't be dropped again, and
ship the *_nowasm_fallback.js files for graceful degradation.

Adds a regression test asserting every .wasm the bundled pdf.js
references is covered by copy-pdfjs-wasm.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 08:52:10 +02:00
Huang Xin de3e4b6d3c fix(reader): show Duokan fullscreen cover in scrolled mode (#4379) (#4381)
A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.

The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.

Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:52:42 +02:00
Huang Xin 53fe8c2683 fix(release): don't clobber latest.json with a 404 "Not Found" body (#4376)
The Android and Windows-portable jobs fetched the existing updater
manifest with `curl -sL .../latest.json -o latest.json`. Without `-f`,
curl writes the server's 404 body ("Not Found") into the file and exits
0, after which `gh release upload --clobber` uploads that invalid JSON as
the release's latest.json.

tauri-action's updater step then downloads the existing latest.json and
runs `JSON.parse(...)` to merge new platforms in. Parsing "Not Found"
throws `Unexpected token 'N', "Not Found" is not valid JSON`, failing
every build-tauri matrix leg after its bundles were already uploaded.

Use `curl -fsSL` so an HTTP error fails the step instead of writing the
error body, and validate the download with `jq empty` before merging, so
a corrupt manifest can never be clobbered onto the release again.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:17:32 +02:00
Huang Xin f8d88cca50 chore: bump tauri plugins (#4373) 2026-05-30 22:18:41 +02:00
Huang Xin 99e551cbc6 chore: fix release workflow (#4372) 2026-05-30 21:48:48 +02:00
Huang Xin 97e2aa2797 release: version 0.11.2 (#4371) 2026-05-30 21:23:22 +02:00
Huang Xin d0071a6bcb fix(sync,reader): discard malformed sync CFIs; fix swipe background flash (#4370)
sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g.
epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate
to the wrong end of the section. Add isMalformedLocationCfi and discard such
locations on the cloud-sync receive path (useProgressSync) and the kosync push
path (useKOSync) so they can't move the reader or propagate to other devices.
foliate 569cc06 stops generating them but does not repair already-synced values.

reader: bump foliate-js to 167757a to fix the white<->black background flash
when swiping between differently-colored pages; add a regression test for the
sliding per-view background segments.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:02:52 +02:00
Huang Xin ef603852b7 feat(tts): hotkey to highlight the currently-spoken sentence (#4085) (#4368)
Add a "Highlight Current Sentence" keyboard action (default Shift+M, in the
Text to Speech shortcut section) that persists the sentence TTS is reading
aloud as a normal highlight using the user's default style/color — no text
selection, eyes-off, silent, and idempotent (a repeat press on the same
sentence is a no-op rather than a duplicate).

Flow: the shortcut handler in useBookShortcuts dispatches tts-highlight-sentence
→ useTTSControl (which owns the TTSController) resolves the current sentence via
the new TTSController.getSpokenSentence() and relays create-tts-highlight
→ Annotator builds the BookNote with the pure, unit-tested buildTTSSentenceHighlight
helper and persists/renders it like any other highlight.

Closes #4085

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:33:28 +02:00
Huang Xin c23c21d37d fix(kosync): reflowable conflict comparison via local CFI; scrolled-mode + library fixes (#4367)
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage

KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared.

Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): correct scrolled-mode reopen drift over background-image sections

Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(library): redirect to login on pull-to-refresh when signed out

Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(memory): add kosync conflict + toc/scrolled-restore notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): prevent CFI crash on inert-only section bodies

Reopening/​paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(kosync): keep auto-push working when a pull finds no real conflict

In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(settings): show most recent sync time and reorder settings tabs

Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 18:07:29 +02:00
Huang Xin 11666be5ee fix(reader): collapse TOC to the current chapter's path by default (#4366) 2026-05-30 17:37:59 +08:00
Huang Xin 92b3c9db48 fix(kosync): resolve progress CFI via its own spine section (#4364)
generateKOProgress built its XCFI converter from the paginator's
primaryIndex and the rendered primary document, then converted
progress.location. Because #primaryIndex can lag behind the viewport
during scrolling, the CFI's spine section could differ from the
converter's, tripping XCFI's guard ("CFI spine index N does not match
converter spine index M") and silently dropping the progress push.

Route through getXPointerFromCFI, which keys off the CFI's own spine
index and loads the correct section's document from the book when the
rendered index doesn't match. Fall back to the cached config.xpointer
on failure.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:17:58 +02:00
Huang Xin aa318904b5 fix(reader): show full bookmark ribbon in scrolled mode header (#4365)
In scrolled mode, SectionInfo paints a solid `bg-base-100` `notch-area`
mask over the top safe-area strip at z-10. The Ribbon was also z-10 but
rendered earlier in the DOM, so the equal-z mask painted over the
ribbon's upper (unsafe-area) half — only the lower 44px showed. In
paginated mode the mask has no background, so the ribbon showed fully.

Raise the ribbon to z-20 so the whole ribbon stays visible above the
mask, and mark it pointer-events-none so taps still fall through to the
notch mask's scroll-to-top handler.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:16:07 +02:00
Huang Xin 6605ae8242 feat(dictionary): import companion MDD files that share the MDX prefix (#4363)
A single MDX commonly ships its resources across several MDD files sharing the MDX's filename prefix (e.g. `Name.mdd` for images, `Name-02.mdd` for scripts, `Name-03.mdd` for audio). The importer only grouped the exact-stem `Name.mdd`, so the other MDDs were dropped as orphans and their resources (notably audio) could never be loaded.

`groupBundlesByStem` now attaches every `.mdd` whose stem starts with an `.mdx` stem at a separator boundary to that MDX bundle (longest prefix wins on overlap); the boundary check prevents false merges like `dict.mdx` claiming `dictionary-words.mdd`. The runtime provider, contentId, and replica sync (binary upload + manifest apply) already treat `files.mdd` as a list, so multi-MDD bundles sync across devices with no further changes.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:30:54 +02:00
Huang Xin 78794499a2 fix(dictionary): correct System Dictionary platform gating on web and iPad (#4362)
* fix(dictionary): keep other dictionaries usable when System Dictionary syncs to an unsupported platform

`dictionarySettings.providerEnabled` is whole-field synced across devices, so enabling System Dictionary on macOS/iOS sets the flag on web/Linux/Windows too. There the row is hidden and the feature is a no-op, but the settings UI read the raw flag and locked every other dictionary's toggle read-only. Gate the lock on `isSystemDictionaryEnabled(settings)` — the same platform-aware check the annotator uses — so it matches real lookup behavior and never triggers where the system dictionary can't run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dictionary): dispatch system dictionary handoff by native OS (fixes iPad)

iPadOS sends a desktop "Macintosh" user agent, so the UA-based `getOSPlatform()` reported iPad as 'macos' and the handoff invoked the macOS-only `show_lookup_popover` Rust command that iOS never registers ("Command show_lookup_popover not found"). Derive the OS from the app service's `is*App` capability flags (sourced from the Tauri OS plugin, correct on iPad) via a new synchronous `getInitializedAppService()` accessor, so iPad routes to the iOS plugin command path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): constrain reader View Options dropdown to h-8

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(agent): note System Dictionary platform-detection and synced-flag patterns

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 09:51:22 +02:00
Huang Xin f1ae050768 fix(ui): refine reader side panels and their empty states (#4361)
* docs(agent): add agent notes for cache, reading-ruler, foliate touch

Add project-memory notes and index entries:
- manage-cache-ios-layout: iOS container layout and what Manage Cache clears
- reading-ruler-line-aware: line/column-aware reading ruler internals
- foliate-touch-listener-capture-phase: capture-phase gesture suppression

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): pad sidebar and notebook for the device status bar (#4089)

Top-anchored slide-in panels (sidebar, notebook) only applied status-bar
top padding when isFullHeightInMobile was true. On a tablet/desktop
(isMobile === false) that gate collapsed the padding to 0, so a visible
system status bar overlapped the panel's top toolbar and made its icons
inaccessible.

Extract the inset math into getPanelTopInset() and gate it on
(!isMobile || isFullHeightInMobile) so non-mobile panels clear the status
bar like the reader header, while a partial-height mobile bottom sheet
(which doesn't reach the top of the screen) stays flush.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): keep footer bar clear of the pinned sidebar

On a mobile tablet in portrait, forceMobileLayout renders the footer bar
with position: fixed, anchored to the viewport, so left-0 w-full spans
the whole window and slides under a pinned sidebar — the progress / font
/ TTS controls end up obscured.

Anchor the footer inside the book's grid cell (position: absolute) when
the sidebar is pinned, mirroring the header bar. The flex layout already
offsets the grid cell by the sidebar's real rendered width, which honors
the sidebar's min-w-60 floor and 45% cap that a stored-width offset would
miss. The slide-up panels are absolute within the footer container, so
they shift and narrow with it and their animation is unchanged. The
switch only happens when the sidebar is pinned, so phone (< 640px) and
unpinned tablet-portrait class names stay identical.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): refine reader side panels and their empty states

Closes #4089

Add a shared EmptyState component (large muted icon, title, and an
optional hint or action) and use it for the empty annotations, bookmarks,
and notes panels in the sidebar and notebook, replacing the ad-hoc
"No … yet" placeholders.

Polish the surrounding chrome: switch the bookmark toggler to the Ri icon
set with responsive sizing, crop the HighlighterIcon viewBox to its
artwork to remove the asymmetric bottom padding, and tune mobile sizing
and spacing across the panel headers, tab navigation, and footer nav bar.

Translate the new empty-state strings (No Notes, No Annotations, No
Bookmarks, and their hints/action) across all 33 locales and drop the
obsolete "No … yet" keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 08:29:41 +02:00
Huang Xin bed31e8181 feat(library): add Manage Cache to advanced settings (#4359)
Add a "Manage Cache" item to the library Advanced Settings menu (native
mobile apps only) that opens a modern dialog showing the combined size and
file count of the app's reclaimable storage, with a confirm-gated clear that
reports per-file progress.

- iOS clears Cache + Temp + Documents/Inbox; Android clears Cache + Temp.
- Multi-source helper (getCacheEntries/getCacheStats/clearCacheEntries) with
  unit tests; per-file failures are counted, never abort the run.
- Dialog uses the centered-hero + btn-contrast design language, theme-neutral
  progress, and is e-ink correct.
- i18n: new strings translated across all locales (+ en plural forms).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:57:07 +02:00
Huang Xin 789d031222 feat(reader): line-aware reading ruler (#4358)
Snap the reading ruler to real rendered text lines instead of stepping by a
fixed arithmetic height, so the band always frames whole lines.

- Snap to actual line geometry from the relocate range; the band is sized
  dynamically to the text block plus symmetric padding (round(fontSize *
  lineHeight * 0.3)), capped at (lines + 1) line heights so a tall image inside
  a block can't expand it to cover the whole figure.
- Drop block/container rects (Range.getClientRects aggregates multi-line element
  borders) so paragraphs aren't merged into one giant line and skipped.
- Column-aware in multi-column layouts: the band spans one column at a time and
  advances column by column.
- Confine the band to lines at least half visible within the viewport.
- Scrolled mode: snap to lines, and at a view edge scroll the view and realign
  the band to the start/end of the new view (works for vertical-rl too, which
  scrolls horizontally); paging snaps the view edge between lines so text isn't
  cut or repeated.
- Vertical writing mode: correct band centering and drag direction; Up/Down keys
  move the ruler while Left/Right turn pages (taps always move the ruler).
- Page turns keep the first/last line: forward lands on the first line of the new
  page, backward on the last line; the relayout re-snap anchors on the band's
  leading edge so it never skips a line.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:16:53 +02:00
Huang Xin 36e11de332 feat(reader): swipe-to-adjust brightness gesture on mobile (#3021) (#4356)
* docs: design spec for gesture-based brightness control (#3021)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: revise brightness-gesture spec per /autoplan review (#3021)

CEO+Design+Eng dual-voice review. Key fixes: capture-phase listener
(bubble-phase could not suppress foliate paginator), opt-out toggle,
18px threshold, selection guard, brightness seed race, rAF teardown,
e-ink stepped overlay, contrast capsule, perceptual curve reuse,
listener-level test harness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reader): swipe-to-adjust brightness gesture on mobile (#3021)

Left-edge vertical swipe adjusts screen brightness on iOS/Android, with a
Sun-icon progress overlay. Capture-phase non-passive listener suppresses the
foliate paginator / page-flip / UI-toggle handlers; selection guard, strip
reservation in scrolled mode, eager brightness seed, rAF throttle + teardown.
Opt-out toggle in Settings > Behavior > Device (default on). Perceptual curve
shared with the menu slider. Pure-helper + listener-level tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reader): detect brightness-swipe edge by screenX; i18n + shorter label (#3021)

On-device fix: paginated mode lays the iframe doc out as wide side-by-side
columns, so clientX/documentElement.clientWidth are document coordinates and a
left-edge touch on a later page never fell inside the strip (armed stayed false).
Detect with screenX against the parent window width, matching usePagination.

Also: translate the two new setting strings across all locales and shorten the
toggle description to 'Slide along the left edge'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 15:28:42 +02:00
Huang Xin 89723b421e test(rsvp): stop RSVPController tests leaking real timers into teardown (#4355)
rsvp-controller.test.ts calls controller.start() in ~20 tests but never stops
the controller. start() schedules a countdown (setInterval, 500ms x3) that then
schedules the recurring word-advance (setTimeout). On the real clock those fire
~1.5s later — after the test file's jsdom env is torn down — and
emitStateChange's dispatchEvent(new CustomEvent(...)) runs against a stale realm
and throws. Vitest reports it as an unhandled error and fails the whole run
intermittently on CI:

  TypeError: Failed to execute 'dispatchEvent' on 'EventTarget': parameter 1 is
  not of type 'Event'.
   at RSVPController.advanceToNextWord -> emitStateChange (Timeout._onTimeout)

Fake only the timer functions (setTimeout/setInterval + their clears) for this
suite via vi.useFakeTimers({ toFake: [...] }); useRealTimers in afterEach
discards any still-pending fakes. The tests assert synchronously and never
advance playback, so faking the timers changes no assertion; Date/performance
stay real so CFI/position checks are unaffected. Test-only; production behaviour
is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:51:00 +02:00
loveheaven 7bdd3ecdee perf(sidebar): virtualize BooknoteView and memoize derivations (#4352)
Switching the annotation/bookmark sidebar to a flat virtualized list eliminates
the per-item layout reads that caused multi-second jank when toggling tabs on
books with hundreds of notes.

A. Virtualize the list with react-virtuoso
   - Flatten group headers + notes into a single FlatBooknoteRow array.
   - Embed an OverlayScrollbars instance inside the tab so scrollbar styling
     is preserved while Virtuoso owns the viewport (same nested pattern as
     TOCView).
   - Track the parent scroll-container's height with ResizeObserver to give
     Virtuoso a bounded viewport.
   - Replace the per-item useScrollToItem (which called getBoundingClientRect
     and closest() on every BooknoteItem on every progress tick — O(n) sync
     reflow on 1000+ items) with a single virtuosoRef.scrollToIndex driven by
     nearestCfi.

B. Stabilize derivations with useMemo / useCallback
   - filteredNotes, sortedGroups, flatItems, nearestCfi all useMemo so an
     unrelated config change (e.g. viewSettings autosave) no longer triggers
     a full sort + group rebuild.
   - handleBrowseBookNotes is now useCallback so BooknoteItem's React.memo
     can hit on prop equality.

C. Memoize BooknoteItem
   - Wrap the component in React.memo. With stable item / onClick references
     from the parent, re-renders triggered by sibling progress updates no
     longer cascade across every visible row.
   - Cache marked.parse(item.note) and dayjs(item.createdAt).fromNow() in
     useMemo. marked is the dominant per-render cost for note rows.
   - isCurrent moves to a useMemo over isCfiInLocation; the per-item
     scrollIntoView is removed since BooknoteView now drives scrolling.

useScrollToItem is intentionally left intact — SearchResults still uses it
and its smaller list does not exhibit the same jank.
2026-05-29 10:25:38 +02:00
Huang Xin a848c142c8 test(reader): de-flake scrolled-mode backward-preload precondition (#4112) (#4354)
The two #4112 scrolled-mode preload browser tests asserted that the
previous-previous section (F-1) was not loaded after navigating to a section,
checking it after `waitForFillComplete()`.

The eager backward buffer (minPages) is suppressed while `#display` is
stabilizing, but pulls F-1 (and beyond) in on the first scroll events once the
fill settles. So the post-fill assertion raced the buffer: it passed locally but
failed intermittently on CI (loaded set [1,2,3,4,5]).

Assert the precondition the instant `#display` stabilizes — where the buffer is
provably suppressed and only the immediate-previous section is loaded — before
the fill settles. The eager buffer loading F-1 later is exactly what the second
test wants once it navigates back. Test-only; production behaviour is unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:22:58 +02:00
Huang Xin 6405ba31c8 fix(reader): keep TOC scrolled to the current chapter on refresh (#4353)
On a hard refresh the TOC sidebar occasionally (~1 in 10) scrolled to and
highlighted the current chapter, then rewound to the very top of the list.

It is a scroll-position race in TOCView, not a progress/sectionHref reset (the
reading position stays correct throughout). OverlayScrollbars resets the wrapped
Virtuoso viewport's scrollTop to 0 when it initializes (deferred). Its
`initialized` callback re-scrolled only to `initialScrollTarget.index`, captured
at mount — and on a fresh refresh `progress` is not available yet, so that index
is 0 and the reset is never corrected. Whether OverlayScrollbars initializes
before or after the auto-scroll to the reading position is the timing race that
made it intermittent.

Re-apply the scroll to the current active item (via refs mirroring the live
flatItems/activeHref) in the `initialized` callback, falling back to the
mount-time index. Refs are used because OverlayScrollbars binds the callback at
mount and fires it later, so it must read the latest active item.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:22:30 +02:00
Huang Xin 4988a53cba i18n: translate Import Annotations + OPDS catalog strings across locales (#4351)
Extract and translate the new strings introduced by the Moon+ Reader
annotation import flow (#4350, #4174) and OPDS facet/catalog navigation
(#4348): import dialogs, link-type labels, catalog actions, empty states,
and the count-pluralized "Failed to import {{count}} books" title.

- 21 singular keys + plural forms translated across all 32 non-en locales
- en/translation.json gains the hand-authored _one/_other plural variants
  for "Failed to import {{count}} books"
- plural forms follow each locale's CLDR categories (ar 6, ru/pl/uk/sl 4,
  romance 3, etc.); placeholders, "Moon+ Reader" brand, and ".mrexpt" preserved

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 08:50:15 +02:00
Zeedif c5a1a3afeb feat(opds): add facet navigation and quick catalog registration in header (#4348)
* feat(opds): add facet navigation and quick catalog registration to header

- Add an options dropdown in the header to navigate OPDS feed facets on compact viewports. - Implement an "Add to My Catalogs" dialog to save the current feed, inheriting credentials, headers, and config. - Render a standalone shortcut button in the header when no facets are present, hiding the dropdown.

* fix(opds): scope window rounding to full route + guard duplicate add

Reviewing the facet-navigation feature surfaced three issues in the
quick-add flow and an unrelated window-rounding change:

- Restore the standard full-screen-route rounding pattern on the OPDS
  browser. The header change had dropped the `isRoundedWindow` guard
  (rounding maximized/fullscreen windows leaves gaps at the edges) and
  switched to left-only corners (a docked-sidebar pattern). Match the
  library/auth/user/reader pages: `isRoundedWindow && window-border
  rounded-window`.
- Guard "Add to My Catalogs" against re-adding a catalog whose URL is
  already saved. `addCatalog` dedups by contentId and would silently
  overwrite the existing entry while toasting "added successfully"; now
  it detects the duplicate via `findByUrl` and shows an info toast.
- Replace `feed!.facets!` non-null assertions with optional chaining.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:58:04 +02:00
Huang Xin 2f5e583653 feat(annotations): configurable export link type + dedicated Import Annotations modal (#4350)
* feat(export): make annotation export link type configurable

Add an Annotation Link selector (App / Web) to the Export Annotations
dialog. Defaults to the app deeplink in the native app and the universal
web link on the web, so web exports no longer emit readest:// links that
only the desktop/mobile app can open. The default markdown template now
uses the configurable annotation.link variable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(annotations): move Moon+ Reader import into a dedicated Import Annotations modal

Replace the single 'Import from Moon+ Reader' menu item with an 'Import
Annotations' entry (below 'Export Annotations') that opens a dedicated
modal listing import sources. Currently lists Moon+ Reader; the boxed-list
layout makes adding future providers a one-row change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:48:00 +02:00
Huang Xin 3c14d5a4b9 fix(reader): scrolled-mode prev-section preloading and nav drift (#4112) (#4349)
Bump foliate-js: compensate the scroll position when a previous section is prepended above the viewport, so the browser's scroll-anchoring suppression at scrollTop 0 no longer drifts the view into chapter n-1; preload the previous section on backward navigation and eagerly while scrolling toward the top; and stop the blank-screen flash on adjacent navigation in continuous scrolled mode.

Split paginator-multiview.browser.test.ts into paginator-scrolled and paginator-paginated, adding regression tests for the drift, previous-section preloading, the eager backward buffer, and the flash-free transition.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 07:16:25 +02:00
Huang Xin ce0ab5cc61 feat(library): add secondary "Then by" sort with smart defaults (#4347)
Adds a primary/secondary sort pair so users can group by author and have
each author's books drilled-in list sort by series, without touching the
sort menu each time. Closes #4307.

- New "Then by..." picker in the library view menu (None + same keys as
  primary). Secondary acts as tiebreaker for the global sort, and as the
  in-group ordering when the user drills into a non-series group.
- Smart defaults derived from groupBy, surfaced as "(Auto)" in the menu
  and resolved at sort time so user picks are never overwritten:
  - groupBy=Author + secondary=none -> Series
  - groupBy=Series + librarySortByAuto -> primary becomes Series
- librarySortByAuto flips off as soon as the user makes any explicit
  primary pick; subsequent groupBy changes then respect that choice.

Settings: librarySortBy2, librarySortByAuto. URL: ?sort2 syncs the
secondary; auto is settings-only (no URL representation).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:24:49 +02:00
Huang Xin 10a223b0e2 fix(export): export uses save dialog on Windows to avoid share UI freeze (#4343) (#4346)
Windows WebView2's native share UI, invoked via tauri-plugin-sharekit,
synchronously blocks the Tauri command thread waiting on
ShareCompleted/ShareCanceled callbacks. When the user dismisses the
picker without those callbacks firing, the app freezes and has to be
killed from Task Manager. Skip the native share path on Windows and
fall through to the save dialog, mirroring Linux.
2026-05-28 19:09:39 +02:00
Huang Xin 18c2115cc1 feat(library): import-failure modal + group sort + Android callout fix (#4345)
* fix(library): suppress Android image callout on book covers

Long-pressing a cover on Android could trigger the WebView's native
image callout at the same time as the bookshelf's own 500ms long-press
handler for multi-select, causing apparent freezes. `-webkit-touch-
callout: none` doesn't inherit, so the existing `.no-context-menu`
rule on the item container never reached the cover `<img>`. Apply the
callout suppression to descendant images/anchors and disable native
drag on the cover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(library): show modal for multi-file import failures

When a batch import yields more than one failure, the previous toast
crammed every filename onto a single line that often overflowed and
truncated. Add a dialog that lists each failed filename with its
error reason, dedupes the message into a header banner when every
file failed for the same reason, and falls back to the existing toast
for single-file failures.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(library): sort manage-group modal by most recent activity

The Group Books modal listed groups in store-insertion order, which made
recently-active groups hard to find in libraries with many groups. Sort
each level desc by the newest `updatedAt` across the group's books,
propagating up the path so a recently-touched book in
`Literature/Fiction` keeps `Literature` fresh too. Extract the index as
`buildGroupNameUpdatedAt` in libraryUtils for reuse and unit testing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(library): tighten select-mode action bar and header polish

- SelectModeActions: switch the narrow-viewport grid from 3 columns to
  4 (with the delete action explicitly placed in column 2) so the icon
  set stops wrapping awkwardly on phones below ~500px.
- LibraryHeader: keep the "Select All" / "Deselect" label on a single
  line so it doesn't wrap and shove the underlying button taller.
- SetStatusAlert: drop the hover bg on the small-screen cancel button
  and rely on text-color contrast so it stops flashing a tinted disc
  on mobile taps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:46:17 +02:00
loveheaven 3c134380b7 feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import (#4338)
* feat: add empty state hints and loading indicators for annotations, bookmarks, notes, font import, and Moon+ Reader import

- BooknoteView: show 'No annotation yet' / 'No bookmark yet' when empty
- Notebook: show 'No note yet' when no notes/excerpts exist
- Annotator: add loading overlay with spinner during Moon+ Reader import
- mrexpt: yield to event loop every 5 entries to keep spinner animating
- CustomFonts: show in-place loading card during font import, spinner transitions to font name without layout jump

* fix(ui): respect e-ink overlay styling and drop dead className branch

- Annotator: use modal-box on the mrexpt import overlay so eink picks up
  the no-shadow + 1px border override automatically.
- CustomFonts: collapse importing-card clsx ternary whose branches were
  identical into a flat className.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:37:04 +02:00
Huang Xin bb81d6270f fix(reader): keep Android paginated text selection from jumping back to first rendered section (#4342)
The Android-only workaround that pinned the container scroll while text was
selected saved `renderer.start` (section-relative) and restored it as
`renderer.containerPosition` (absolute). On later sections those two
diverge — restoring the small `start` value as `containerPosition` snapped
the multi-view scroll back to the first rendered section whenever the OS
selection handle drag triggered a scroll. Save and restore the same
`containerPosition` value instead.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:07:49 +02:00
Huang Xin e29331bea9 fix(sync): prevent cross-device progress overwrite; retry first pull on flaky networks (#4341)
Closes #4222.

Three changes that together stabilize Readest sync across devices:

**Stop the artificial updatedAt bump in useProgressAutoSave**

saveConfig unconditionally bumps config.updatedAt = Date.now(), and
useProgressAutoSave used to fire saveConfig on the very first relocate
after book open — even though that relocate just reflects the position
loaded from disk, not user action. The stale local config then looked
"newer" than a fresher server-side push, so the next auto-push
overwrote the other device's real progress via last-writer-wins. The
hook now snapshots the loaded location and skips saveConfig when the
in-memory location still matches it.

**Retry the first config pull with backoff + release the gate**

useProgressSync gates pushes behind a successful pull (so a brand-new
import can't clobber the server's real progress). But handleAutoSync
only re-arms on progress.location changes, so a single failed pull
(Android cold-start contention, Wi-Fi/LTE handoff, captive portal)
used to block every push for the whole reader session. The new
pullWithRetry retries on backoff (1500/4000/10000 ms) and releases
the gate after exhaustion — server-side last-writer-wins still
protects the cross-device case (a stale local push with an older
updated_at loses to a fresher server record). sync-book-progress
events reset the chain so manual pull-to-refresh recovers cleanly.
Fetch timeout bumped from 8s to 15s to better tolerate slow networks
in that cold-start window.

**Server piggybacks books.progress off configs push**

/api/sync POST now updates books.progress + books.updated_at for each
upserted config, gated by .lt('updated_at') so a concurrent newer
books push is never downgraded and a missing row is a silent no-op
(useBooksSync still seeds new rows from the library page). The
in-reader syncBooks round-trip is dropped — the reader now sends one
POST per auto-save instead of two, and the books row stays consistent
with config pushes even while a reader stays open (#4198).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:55:07 +02:00
Huang Xin 48d52ea898 feat(telemetry): opt-out by default for new users; consent prompt for 10% (#4340)
Fixes #4339. PostHog telemetry was previously enabled by default for every
install, which surprised privacy-conscious users on self-hosted setups.

Behavior for new users only (existing users are migrated to a decision that
preserves their current `telemetryEnabled` setting):
  - 90% are silently opted out on first launch.
  - 10% see a one-time consent prompt; accepting opts in, declining opts out.
Decision is persisted via a new `readest-telemetry-decision` localStorage key
so subsequent boots don't re-roll. PostHog now inits with
`opt_out_capturing_by_default` so brand-new users never ping before the
decision is finalized.

New `TelemetryConsentDialog` uses the project's `btn-contrast` (theme-neutral)
CTA and `eink-bordered` chassis so it renders correctly under `[data-eink]`
without color-mode-only assumptions. Strings translated across all 33 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:19:05 +02:00
Huang Xin 744d5b3a03 fix(dict): restore MDD eager init; CSP-safe audio handler; gate binary uploads on sync category (#4337)
* fix(dict): restore MDD eager init; CSP-safe Vocabulary.com audio handler

Two MDict fixes:

1. **Regression**: #4334 turned on `MDD.create(..., { lazy: true })` so
   the audio / resource side of every MDict would skip the upfront
   key-block decode + sort. That's the right trade-off on the MDX side
   (millions of headwords, ~80 s init saving), but it doesn't transfer
   to MDDs: js-mdict's lazy lookup binary-searches `keyInfoList`
   envelopes with `comp` (localeCompare), and MDDs store keys in
   byte-sorted order with `\` prefixes and case folding. The two orders
   disagree just often enough that `mdd.locateBytes('sound.png')`
   misses on resource paths that do exist — the icon's CSS
   `background-image: url(sound.png)` then stays unresolved and the
   speaker glyph disappears. Bisected to 93abca89 (#4334). Drop the
   flag for MDDs; MDXs keep the lazy win.

2. **New**: a CSP-safe replacement for the `onclick="v0r.v(this,'KEY')"`
   audio handler used by Vocabulary.com-derived MDicts. The dict's
   `j.js` is never loaded inside our shadow root (we don't execute
   MDX-supplied JavaScript), so without intervention the inline
   handler would throw `ReferenceError: v0r is not defined` on every
   click. The new helper parses the audio key from any matching
   onclick, strips that one attribute, and binds our own listener that
   probes the companion MDD for `<KEY>.mp3` / `<KEY>.m4a` / etc. and
   plays the bytes through an `<Audio>` element. Other unrelated
   inline handlers are deliberately left alone — they were no-ops
   already (their helper JS isn't loaded), and CSS rules sometimes
   match on `[onclick]` attribute presence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): skip binary upload when sync category is disabled

`publishReplicaUpsert` already gates the metadata row on
`isSyncCategoryEnabled(kind)` so a user who turned dictionary / font /
texture sync off in Manage Sync never publishes a row for that kind.
`queueReplicaBinaryUpload` had no such gate, so the binary side still
fired: a 250 MB MDict bundle or a few hundred MB of font files would
still upload to cloud storage with no replica row to reference them.

Mirror the gate here. The `kind` parameter (`dictionary`, `font`,
`texture`) lines up with `SyncCategory` names verbatim, so a single
`isSyncCategoryEnabled(kind)` check at the top of the function is all
it takes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 10:13:18 +02:00
Huang Xin 648c35b334 feat(reader): add disableSwipe option to disable swipe-to-paginate (#4335)
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers)
want to disable swipe-to-paginate so accidental finger drags during
highlight selection don't flip the page mid-annotation. The existing
"Tap to Paginate" toggle only covers taps; swipe was always on.

- New `disableSwipe: boolean` on `BookLayout` (default `false`,
  declared right after `disableClick`).
- foliate-js submodule bump: paginator gates `#onTouchMove` and
  `#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so
  native touch behaviour (text selection) stays intact.
- `FoliateViewer` sets/removes the `no-swipe` attribute alongside
  `animated`, and the `ControlPanel` toggle pushes the change to the
  live renderer so it takes effect without a viewer reset.
- The fixed-layout swipe interceptor in `usePagination` also bails
  when `disableSwipe` is on, covering both reflowable and fixed-
  layout books.
- New "Swipe to Paginate" UI row directly below "Tap to Paginate";
  both can be off simultaneously.
- i18n: 33 locales translated.

Also polish: rephrase the two helper texts under "Read books in
place" in `ImportFromFolderDialog`. The previous copy ("Copy no book
into the library to save space.") used an awkward double-negative;
the new wording is clearer and the locked variant drops "registered
as" / em-dash for a plain two-sentence form. i18n updated.

Closes #4288

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:38:38 +02:00
dependabot[bot] 462adc500d chore(deps): bump the github-actions group with 6 updates (#4333)
Bumps the github-actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github/codeql-action](https://github.com/github/codeql-action) | `4.35.5` | `4.36.0` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3.12.0` | `4.1.0` |
| [docker/login-action](https://github.com/docker/login-action) | `3.7.0` | `4.2.0` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5.10.0` | `6.1.0` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6.19.2` | `7.2.0` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `7.0.0` | `8.0.1` |


Updates `github/codeql-action` from 4.35.5 to 4.36.0
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

Updates `docker/setup-buildx-action` from 3.12.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f...d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5)

Updates `docker/login-action` from 3.7.0 to 4.2.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/c94ce9fb468520275223c153574b00df6fe4bcc9...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

Updates `docker/metadata-action` from 5.10.0 to 6.1.0
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/c299e40c65443455700f0fdfc63efafe5b349051...80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9)

Updates `docker/build-push-action` from 6.19.2 to 7.2.0
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/10e90e3645eae34f1e60eeb005ba3a3d33f178e8...f9f3042f7e2789586610d6e8b85c8f03e5195baf)

Updates `actions/download-artifact` from 7.0.0 to 8.0.1
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/37930b1c2abaa49bbe596cd826c3c89aef350131...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/metadata-action
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: 7.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-28 07:49:11 +02:00
Huang Xin 93abca8960 feat(dict): faster MDict/StarDict import + lazy lookup; raw .dict; UX (#4334)
Make the dictionary import path usable on large bundles and bring the
multi-device flow up to par.

Import perf
- Skip `MDX.create()` at import time. The factory triggers full init —
  decompresses every key block and sorts millions of keys with
  localeCompare just to expose the header. Replace with a tiny
  `readMdxHeader()` that only reads the small XML header for Title /
  Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web).
- `partialMD5`: read the 9 sample slices in parallel rather than
  sequentially. Each freshly-picked-File slice round-trip on Chrome
  costs ~100 ms cold; parallelisation collapses 9 of them into one.
- Native fast-path in `nativeAppService.writeFile`: when the source is
  a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming
  the file through `NativeFile.stream()`. Streaming a 250 MB body
  through 1 MB IPC chunks on Android took ~100 s; native copy is bound
  by disk throughput instead. Exposes `NativeFile.getNativeLocation()`
  for the FS layer to use the underlying path + baseDir directly.

Lazy lookup
- Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change
  in this PR skips the upfront decompress-every-block + sort during
  init (~80 s on the same 250 MB bundle) and decodes only the relevant
  key block on demand per lookup. First-lookup main-thread block
  drops from ~81 s to ~230 ms. (Closes #4228.)

Raw .dict
- Drop the import-time gate that flagged non-gzip dict bodies as
  `unsupported`. The runtime body loader (`loadDictBody`) already
  probes the gzip header and falls through to a passthrough buffer
  for raw files, so the gate was the only thing preventing raw
  `.dict` bundles from importing on devices that received them via
  cloud sync. (Closes #4179, partially addresses #4248.)

Import-flow UX
- `handleImport` now always surfaces a toast for every non-cancelled
  attempt: picker errors, missing app service, no-op imports, and
  unsupported-but-imported bundles each get their own message instead
  of failing silently.
- Call `markAvailableByContentId(newDict.contentId)` after
  add/replace so the "Bundle is missing on this device" warning
  clears immediately — no need to close-and-reopen the panel.

System Dictionary
- Drop the cascading toggle behavior in `setEnabled`. Each provider's
  enabled flag persists independently; exclusivity is enforced at
  lookup time. Toggling System on/off no longer wipes the user's
  preferred set of in-app providers.
- Render non-system rows as read-only when System is on (toggle still
  shows what's queued to restore; tooltip explains the lock).
- `isSystemDictionaryEnabled` short-circuits to `false` on platforms
  where the handoff isn't implemented. `providerEnabled` is whole-
  field synced across devices, so a flag set on macOS would otherwise
  leak to a Windows device with no way to look up a word.

js-mdict
- Submodule bump to e6dbc99 which adds the opt-in `lazy: true`
  `MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new
  `lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is
  unchanged and every existing js-mdict test still passes.

i18n
- 208 new translations across 33 locales for the new UX strings.

Closes #4228
Closes #4248
Closes #4179

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:48:16 +02:00
Huang Xin 381eed21cc fix(tauri): skip runtime-config.js injection in static export (#4332)
The Tauri build uses `output: 'export'`, so the dynamic `/runtime-config.js`
route handler is never emitted. Requesting it returns the SPA fallback HTML
and crashes with `Unexpected token '<'`. Gate the script tag on
`NEXT_PUBLIC_APP_PLATFORM === 'web'`; Tauri consumers already fall back to
the `NEXT_PUBLIC_*` envs baked in at build time.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 04:14:47 +02:00
Huang Xin 4e01e13ee7 fix(library): make bookitem-main shrink to match cover in fit mode (#4331)
* fix(library): make bookitem-main shrink to match cover in fit mode

Closes #4234. In fit mode the bookitem-main kept its 28/41 aspect
regardless of the cover image's natural aspect, leaving extra padding
beside (portrait covers) or above (landscape covers) the cover. The
select-mode overlay and icons drifted away from the cover edge.

BookCover now reports the loaded image's natural aspect ratio. BookItem
overrides the bookitem-main's aspect-ratio with the cover's aspect so
the box hugs the cover exactly, and proportionally shrinks book-item
width for portrait covers so the info row icons align with the cover's
right edge.

Also wrap the TTS "Back to TTS Location" pill with whitespace-nowrap so
long translations (e.g. German "Zurück zur TTS-Position") expand the
button width instead of overflowing the fixed height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(library): scope cover shrink to bookitem-main, leave info row at cell width

Per review, the width shrink should only apply to the cover row so the
title and info icons keep their original cell-wide layout. Move the
width style from .book-item to .bookitem-main alongside its aspectRatio
override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:25:18 +02:00
Huang Xin a1cb228d00 fix(library): wrap select-mode action bar on small screens (#4329)
Long translations (e.g. German "Gruppieren", "Löschen") pushed the 6-button
action bar past the right edge on typical phones since the grid fallback
only triggered below 350px. Switch to a 3x2 grid below sm: and clamp the
container to the viewport width.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:53:27 +02:00
Huang Xin cf44e85180 fix(reader): fit duokan-page-fullscreen cover image without cropping (#4328)
Closes #3914. Switch the page-fullscreen image to object-fit: contain
(and SVG preserveAspectRatio meet) so cover images fit the page and
center without cropping. Also adds a duokan-image-gallery-cell layout
helper and drops the mobile marginBottomPx override.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:34:55 +02:00
loveheaven 64651a65ef fix(sync): skip replica upload when not authenticated (#4327)
Prevents 'Please log in to continue' error when importing fonts or images
locally without logging in. The replica upload is now gated on
getAccessToken() so unauthenticated users are never enqueued.
2026-05-27 17:34:07 +02:00
Huang Xin d7b633d8f7 i18n: translate new strings for OpenAI-compatible LLM settings and in-place library (#4326)
Translates 26 newly added strings across 33 locales, covering the OpenAI-compatible
LLM provider configuration, Reedy retrieval (beta), OpenRouter fields, in-place
external library messages, and iOS folder import edge cases.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 17:33:15 +02:00
loveheaven 315d144d8a fix(library): suppress loading-dots flicker on reader→library return (#4325) 2026-05-27 17:32:53 +02:00
Huang Xin 1f5481c0e3 fix(fxl): align TTS highlight overlay with scaled iframe coords (#4324)
Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform
while keeping native dimensions inside, so getClientRects() returns
unscaled positions. The SVG overlayer was sized in CSS pixels with no
viewBox, leaving annotations and TTS highlights drawn at scale-1
displacement from the actual text.

Bumps foliate-js to set a matching viewBox on the overlayer SVG, and
adds a regression test covering spread/scroll/PDF/empty frames.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:19:27 +02:00
loveheaven 1294ace9ce fix(file-picker): unblock .mrexpt and other custom extensions on Android (#4323)
The Moon+ Reader import flow asks for .mrexpt files via the 'generic'
preset. On Android, Tauri routes that to the Storage Access Framework
picker, which filters by MIME type. Extensions without a registered MIME
(such as .mrexpt) end up greyed-out and unselectable.

Books and dictionaries already bypass this by using an unfiltered picker
and re-applying the extension whitelist client-side. Extend the same
treatment to the 'generic' preset so callers can request arbitrary
extensions reliably on Android. iOS already had no SAF restriction.
2026-05-27 13:40:08 +02:00
loveheaven ff605e000d feat(library): in-place import from registered external folders (#4315)
* feat(library): in-place import with cloud sync and symmetric local delete

Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.

ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).

Cloud sync treats in-place books as first-class:

  - uploadBook reads bytes from (book.filePath, 'None') when set.
    The cloud key is unchanged, so a peer downloading the book
    lands it under Books/<hash>/ as a normal hash copy.

  - useBooksSync strips `book.filePath` before pushing — it is a
    device-local path that is meaningless on any other device.

  - ingestService no longer skips upload for in-place books;
    autoUpload / forceUpload behave like any other book. Only
    transient imports opt out.

  - deleteBook 'local'/'both' now physically removes the source
    file at book.filePath (base 'None'). Local-delete semantics
    are symmetric with hash-copy books: the local copy is gone,
    the cloud backup remains, a future pull restores under
    Books/<hash>/. removeFile errors are swallowed.

New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.

Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.

* feat(library): one-tap "read in place" toggle in folder import

Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).

User experience:

  - First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.

  - Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.

  - URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.

Mechanics:

  - ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.

  - runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.

  - The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.

Self-healing for externally-removed in-place books:

  Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.

  BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.

  Scope is intentionally narrow:
  - Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
  - Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
  - The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.

* fix(library): centralize book content resolution

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-27 12:08:36 +02:00
loveheaven b5d898a48e fix(document): accept zips whose magic bytes are mangled but have valid EOCD (#4321)
Some EPUBs (e.g. downloaded from Baidu Netdisk) have their first few bytes
corrupted while the rest of the archive is still well-formed. The zip spec
locates archives via the End-of-Central-Directory record at the file tail,
so a corrupted local file header signature does not actually invalidate the
file. When the leading magic check fails, fall back to scanning the last
~64 KiB for the EOCD signature (PK\x05\x06); if present, treat the file
as a zip and let zip.js read it normally.
2026-05-27 11:26:51 +02:00
Huang Xin 647343eab3 agent: update implementation scope (#4319) 2026-05-27 07:50:31 +02:00
loveheaven 5a092f16f7 feat(ios): folder import with security-scoped bookmark persistence (#4314)
* feat(import): support folder picker on iOS via native-bridge

Tauri's dialog plugin rejects folder picks on both mobile platforms with FolderPickerNotImplemented, so previously only Android could pick an import directory (it already routed through the native-bridge plugin's ACTION_OPEN_DOCUMENT_TREE). iOS users had no working folder-import entry point at all.

Add an iOS implementation of the native-bridge select_directory command using UIDocumentPickerViewController(forOpeningContentTypes: [.folder], asCopy: false), with a dedicated FolderPickerDelegate that:

  - holds a strong reference until the picker dismisses (UIKit keeps the delegate weak), and

  - calls startAccessingSecurityScopedResource on the picked URL and retains it for the app's lifetime so plain Foundation/POSIX reads against url.path work for the rest of the session.

Route NativeAppService.selectDirectory through the bridge for both iOS and Android, then call allowPathsInScopes so the picked directory is reachable via fs_scope and the asset protocol. The library page's pickImportDirectory entry point now also takes the mobile branch on iOS, while keeping the Android-only MANAGE_EXTERNAL_STORAGE prompt gated behind isAndroidApp.

* feat(ios): persist security-scoped bookmarks for picked folders

iOS hands the folder picker back a security-scoped URL whose access
right is granted only to the running process. The previous
implementation kept the URL alive for the lifetime of the process via a
static `urlsToKeepAlive` array, which worked for the current session
but forced the user to re-pick the same folder after every relaunch.

Add a `FolderBookmarkStore` that:

  - Right after the picker returns, calls
    `URL.bookmarkData(.minimalBookmark)` and stashes the bytes in
    `UserDefaults` keyed by the POSIX path.

  - On every `NativeBridgePlugin.load(webview:)`, walks every persisted
    bookmark, resolves it back into a URL, and calls
    `startAccessingSecurityScopedResource`. Holds the URL alive in a
    process-scoped dictionary so subsequent Foundation / POSIX reads
    against `url.path` succeed.

  - Handles `isStale` by re-encoding the bookmark against the resolved
    URL, and drops permanently unresolvable bookmarks (folder gone,
    provider uninstalled) from `UserDefaults` so the next launch
    doesn't re-attempt them.

Pair this with a Tauri-side change so the same paths are reachable
through both `dir_scanner::read_dir` and the fs plugin's `readDir`:

  - `allow_paths_in_scopes` now has an iOS branch that widens
    `fs_scope` / `asset_protocol_scope` for any path the frontend hands
    it, intentionally without the desktop-side "must already be in
    fs_scope" gate. The OS sandbox + bookmark store is the real
    access-control boundary on iOS; widening Tauri's in-memory scope
    set cannot escalate access beyond what the OS already grants. The
    security comment on the command was rewritten to spell this
    contract out.

  - `allow_file_in_scopes` is now compiled for iOS too (previously
    desktop-only) so the file-grant path is available when needed.
2026-05-27 07:36:39 +02:00
loveheaven 4c539e6be1 fix(opds): show 'Open & Read' for publications already in the library (#4313)
* fix(opds): show 'Open & Read' for publications already in the library

PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.

Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:

(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
    identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
    foliate-js's opds.js parses into metadata.id — a field
    getMetadataHashInfo never reads. So the identifier candidate list
    was empty and identifier-overlap matching silently skipped every
    book.

(2) Author strings disagree across the boundary: the feed emits
    '<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
    inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
    Plain string equality (even after normalization) never matched.

Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:

- Pass 1: full metaHash equality (the strongest signal — same fingerprint
  the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
  metadata.id into the candidate list alongside metadata.identifier, so
  Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
  extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
  version suffix is ignored) and matches the EPUB's
  'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
  token-set fallback: each name is split on whitespace/comma/semicolon,
  single-letter tokens (initials) and year-range tokens ('1775-1817')
  are dropped, and we require >=2 shared tokens by default. So
  'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
  'Author A' ↔ 'Author B' don't (both collapse to {author} after
  dropping single-letter tokens — we track raw token count to refuse
  the single-token shortcut when discriminative parts were filtered).
  Genuine mononyms still match on a single shared token because raw
  count is 1 on both sides.

OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.

Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.

Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.

* fix(opds): dedupe downloads by source url

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-27 07:32:53 +02:00
Huang Xin 58791e5175 agent: support cross-agent workspace, now it should work with claude and codex (#4318) 2026-05-27 06:28:39 +02:00
Huang Xin 4a5674ef4f chore: bump turso to version 0.6.1 (#4312) 2026-05-26 18:25:28 +02:00
loveheaven 29c88c0216 fix(opds): make Android Back / Reader→Back behave correctly inside the OPDS browser (#4311) 2026-05-26 20:59:18 +08:00
Huang Xin 64492d6551 feat(reedy): wire MemoryConsolidator + live memory providers into ReedyAssistant (#4310) 2026-05-26 18:02:03 +08:00
Huang Xin 6be7606da4 feat(reedy): wire Phase 5 skills + Phase 3 memory tools into the agent runtime (#4309) 2026-05-26 15:31:19 +08:00
Huang Xin e0ce6c8c22 feat(reedy): Appendix A · Phase 4 — custom thread UI on AgentRuntime (#4308) 2026-05-26 14:55:20 +08:00
Huang Xin 4aaf416c43 feat(reedy): Appendix A · Phase 5.1 — SkillRegistry + 3 seed skills (#4306) 2026-05-26 14:53:49 +08:00
Huang Xin 49664ecb75 feat(reedy): Appendix A · Phase 3.2 — MemoryConsolidator (#4304) 2026-05-26 13:46:11 +08:00
Huang Xin 568b8c0a88 feat(reedy): Appendix A · Phase 3.1 — Memory services + memory tools (#4302) 2026-05-26 10:31:07 +08:00
Huang Xin df2698fa0e feat(reedy): Appendix A · Phase 2.6 — AgentRuntime + abort helper (#4301) 2026-05-26 10:14:53 +08:00
Huang Xin a86b09dba5 feat(reedy): Appendix A · Phase 2.5 — PromptContextBuilder + layers + tokenBudget (#4300) 2026-05-26 10:01:57 +08:00
Huang Xin 5c71ccb908 feat(reedy): Appendix A · Phase 2.4 — built-in tools (non-memory families) (#4299) 2026-05-26 09:41:17 +08:00
Huang Xin 4d96c0d54a feat(reedy): Appendix A · Phase 2.1–2.3 — agent runtime foundation (#4298)
* feat(reedy): Appendix A · Phase 2.1 — ChatModel + model registry

First piece of the deferred agent runtime, building alongside the shipped
Phase 1 MVP per the locked decision (no rip-and-replace; legacy + Reedy
MVP + agent paths coexist).

ChatModel is a thin interface over Vercel SDK's LanguageModel that
surfaces contextWindow / reservedOutput / supportsTools — the metadata
the M2.5 PromptContextBuilder and M2.6 AgentRuntime need for prompt
budgeting and tool-calling decisions.

createReedyModels(settings) returns the active (chat, embedding) pair from
the user's currently configured AIProvider, rewrapped in the Reedy
interfaces without touching the legacy AIProvider. CONTEXT_WINDOW_TABLE
hardcodes per-model metadata (Gemini 2.5 → 2M, GPT-5/Llama-4 → 128K,
local Ollama llama → 4K with tools off, etc.) with a conservative 8K
fallback for unknown ids.

No runtime wired yet — Phase 2.6 consumes this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): Appendix A · Phases 2.2–2.3 — runtime event/error taxonomy + ToolRegistry

Phase 2.2 — pure types and factories for everything AgentRuntime.runTurn()
will yield:

  turn_start | text_delta | tool_call | tool_result(ok/err)
    | citation | memory_write | step_finish | usage | error | abort | done

ReedyError covers turn-level failures (context_overflow, turn_timeout,
model_error, provider_unavailable, stream_parse, abort, ...) with default
retryability per kind so the M2.6 streamLoop's retry-with-backoff can
decide without string-matching. ReedyToolError covers tool-execution
failures with a narrower kind union so the streamLoop can re-emit them as
{ tool_result, ok: false, error } events without losing structure.

Phase 2.3 — ToolRegistry that holds the runtime's tool catalog and adapts
each tool to the Vercel ai-SDK ToolSet streamText consumes. Every
registered ReedyTool gets wrapped with:

  - Zod input validation     → tool_invalid_args on mismatch
  - Permission gate          → tool_permission_denied (read auto-approves)
  - Per-call wall-clock      → tool_timeout (default 10s)
  - AbortSignal propagation  → tool_aborted on turn cancel
  - Per-tool serialization   → parallelSafe=false tools queue

The local AbortController per call composes the turn-level signal with
the per-tool timeout so we classify timeout-vs-abort correctly based on
which controller fired first, and listener cleanup avoids leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 02:30:34 +02:00
Huang Xin 6bc4a96b99 feat(reedy): Phase 1B — wire Reedy into the chat, settings, and Sources UI (#4296)
* feat(reedy): wire RetrievalBackend interface + metrics into the chat adapter

Phase 1B backend integration. Adds a RetrievalBackend interface so
TauriChatAdapter holds a uniform reference instead of branching across the
file, with two impls — LegacyIdbBackend (wraps the existing IDB ragService
unchanged) and ReedyBackend (lazy-opens reedy.db, adapts the active
provider's embedding model to Reedy's narrower shape, exposes a Vercel
`lookupPassage` tool). selectBackend() gates Reedy behind both
aiSettings.reedy.enabled AND isTauriAppPlatform() per plan D15 so the MVP
cohort is desktop-only.

The Reedy path streams via streamText({ tools: { lookupPassage }, stopWhen:
stepCountIs(3) }) with a status-aware system prompt that tells the model
how to phrase responses for each RetrieverStatus value.

Replaces the module-global `lastSources` + 500ms poll with a per-instance
ReedySourceStore keyed by a synthetic per-turn id the adapter generates,
so the Sources dropdown stops racing on global state. Both legacy and
Reedy backends now feed citations through the same store; the UI is
backend-agnostic via a shared SourceItem shape both ScoredChunk and
RetrievedChunk satisfy.

Adds the reedy_metrics table to the reedy migration (versioned with
app_version + session_id + turn_id per row) plus a ReedyMetrics writer
that ReedyBackend uses to record indexing-lifecycle and tool-use events.
Always-on local; no network egress. NoopReedyMetrics keeps construction
cheap before the DB opens.

Tests: retrievalBackend selectBackend gates, ReedySourceStore semantics
(append/replace/subscribe/clear), ReedyMetrics debounced batching +
exportBundle, and a TauriChatAdapter contract test that asserts the
Reedy/legacy code-paths pass the right args to streamText.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): UI wiring — settings toggle, clickable sources, feedback bundle

Phase 1B UI integration. AIAssistant now constructs the active backend
(legacy or Reedy) via selectBackend with the platform gate from M1.7,
wires a ReedySourceStore for the chat adapter, and routes Sources-dropdown
clicks to `getView(bookKey)?.goTo(source.cfi)` when the source has a CFI.
Legacy-path sources still render as static rows because they have no CFI.

AIPanel grows a 'Reedy Retrieval (Beta)' BoxedList with the toggle and a
'Send Reedy feedback' button that calls exportReedyMetricsBundle and
triggers a JSON download of the last 90 days of events. The toggle is
disabled on web with an explanatory description per plan D15.

Thread accepts an onSourceClick callback and renders each source as a
button when its source carries a CFI, otherwise as a static div — so the
Sources dropdown is backend-agnostic via the shared SourceItem shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:41:21 +02:00
Huang Xin 7bd3386c20 fix(perf): avoid Layerize storm caused by huge <pre> blocks on Android (#4295) 2026-05-25 20:16:30 +02:00
Huang Xin a1046f5684 feat(reedy): Phase 1A — MVP retrieval primitives (#4293)
* feat(db): add reedy schema migration with Tantivy FTS + lazy embeddings

Registers a new `reedy` migration set bound to reedy.db. Creates
reedy_book_meta + reedy_book_chunks with a Tantivy FTS index on
chunks.text (ngram tokenizer) and a per-book position index used by
BookRetriever. The vector embeddings table is intentionally NOT created
here — the indexer creates it lazily on first index so the vector32(<dim>)
column matches the active embedding model. Tests cover the migration
applies cleanly, is idempotent, and that the FTS index is queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reedy): retrieval primitives — DB, chunker, indexer, retriever, lookupPassage tool

Wires the MVP retrieval pipeline behind reedy.db, all under src/services/reedy/
and with no integration into the existing AI module yet (Phase 1B will do that).

- ReedyDb wrapper over DatabaseService: book-meta CRUD, lazy embeddings
  table at the active model's dim, bulk chunk + embedding writes via batch(),
  hybridSearch (brute-force cosine + Tantivy FTS + reciprocal-rank fusion
  with 3× per-path over-fetch), per-book and global wipe. Internal write
  queue serializes batch() calls so Turso's single-writer transaction guard
  doesn't trip when BookIndexer runs across books in parallel.
- CfiChunker: TreeWalker over the section's DOM, ~maxChunkSize windows with
  paragraph > sentence > word break-points, full epubcfi(/6/N!/…) anchors,
  round-trip verified via CFI.toRange before each chunk lands.
- BookIndexer: per-book mutex, lazy embeddings-table creation, model.batchSize
  embedding batches with dim assertion, terminal status transitions
  (indexed | empty_index | failed). Re-indexing clears prior chunks via a new
  ReedyDb.clearBookChunks helper.
- BookRetriever: status-typed results (ok | not_indexed | empty_index |
  stale_index | degraded). Embedding has a 5s wall-clock budget; on timeout
  it falls through to FTS-only with status=degraded.
- lookupPassage Vercel ai-SDK tool: Zod-validated query/topK, per-turn
  composite-key dedupe, parallel-call serialization, 10s per-turn budget,
  6000-char result clamp, status-with-hint passthrough, and a separate
  serializeForModel that wraps each passage in <retrieved trust="untrusted">
  with XML-escaped content so book text cannot escape the envelope.

59 unit tests; pnpm test + pnpm lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:00:20 +02:00
Huang Xin 2d819b476c fix(db): forward DatabaseOpts to tauri-plugin-turso (#4292)
* fix(db): forward DatabaseOpts to tauri-plugin-turso

NativeDatabaseService.open ignored its opts parameter, dropping any
experimental feature flags (e.g. 'index_method' needed for FTS / vector
indexes) and any encryption config before they could reach the Tauri
plugin. Translate DatabaseOpts to the plugin's LoadOptions shape and
forward as the single argument Database.load accepts. Skip translation
when no relevant opts are set so the existing path-string call shape is
preserved for callers without experimental/encryption needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(db): cover Turso vector primitives + add benchmark harness

Verifies the Turso functions Reedy retrieval depends on, with a
brute-force per-book kNN test that runs against every DatabaseService
backend (node, native, WASM):

  SELECT vector_distance_cos(embedding, vector32(?)) AS d
    FROM book_chunks
   WHERE book_hash = ?
   ORDER BY d ASC LIMIT k

This is the path Turso's own founder recommended in
tursodatabase/turso#3778 ("First, focus on efficient SIMD-accelerated
brute-force search") and what shipped at commit 1aba105df4f. Native
vector index modules don't exist in this engine: `libsql_vector_idx`,
`vector_top_k`, and `USING vector/hnsw/diskann/ivfflat` all parse-error
against @tursodatabase/database@0.6.0-pre.28 (libsql_vector_idx is a
libSQL/sqld fork feature; DiskANN was closed not-planned upstream in
#832). The test asserts cross-book isolation and nearest-first ordering
using only `WHERE book_hash = ?` and `ORDER BY` — no DDL, no identifier
interpolation, no index plumbing.

Also adds bench/ harness for manual perf checks:

  pnpm bench [name]            run benchmarks (refuses in CI)
  pnpm bench --list            list available benchmarks
  pnpm bench --no-record       skip results.jsonl append
  pnpm bench --force           override the CI guard

Uses Node 24's --experimental-strip-types so no tsx devDep is needed.
Appends one JSON line per run to bench/results.jsonl (gitignored, local
history; share by pasting tabular stdout into PRs/issues). Explicitly
NOT in CI — shared-tenant variance makes synthetic-benchmark regression
detection unreliable; production telemetry (reedy_metrics, plan §M1.9)
is the right tool for that.

First benchmark: vector-retrieval. Measured on M1 Pro:
  400 chunks ×  384 dim  →  0.35 ms / query
  400 chunks ×  768 dim  →  0.45 ms / query
  2000 chunks × 768 dim  →  2.23 ms / query
  10000 chunks × 768 dim → 14.00 ms / query
  400 chunks × 1536 dim  →  0.70 ms / query

Per-chunk cost ~1.1 µs at 768 dim = ~1.4 ns/dim. NEON-class on
Apple Silicon, ~50× faster than scalar — confirms SIMD acceleration is
active in 0.6.0-pre.28. Per-query latency stays sub-ms at Reedy MVP
corpus sizes; the ceiling is ~10K chunks per book before phone-class
hardware notices.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:51:49 +02:00
loveheaven 98049282eb feat(ai): add OpenRouter provider and unify provider HTTP transport (#4289)
* OpenRouter: new OpenAI-compatible provider with chat + embedding model
  routing, health check via /models, and a fetchOpenRouterModels() helper
  for the settings UI. API key, base URL and model fields are persisted in
  AISettings, surfaced in AIPanel, indexed by commandRegistry, and added
  to backupService's credential allow-list so the key round-trips through
  encrypted backups.

* utils/httpFetch: introduce getAIFetch() as the single decision point for
  outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch
  (Rust/reqwest transport, no renderer CORS preflight, no Android cleartext
  block); on the web build it falls back to window.fetch. OllamaProvider
  is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags
  health probe — and the new OpenRouterProvider uses the same path, so any
  future provider only has to call getAIFetch().

* Tests: unit tests for OpenRouter provider behavior (model selection,
  availability, health check) and a backup-settings round-trip test
  ensuring openrouterApiKey is treated as a credential field.
2026-05-25 18:32:07 +02:00
loveheaven 7324b2b2bf fix(ios): don't crash app init when Storefront region code is unavailable (#4291)
`getStorefrontRegionCode` rejects when `Storefront.current` is nil
(unsigned simulator, signed-out device, StoreKit not yet ready,
parental controls, etc.). The call sits in NativeAppService startup
before `prepareBooksDir`, so an unhandled rejection here aborts the
rest of init and surfaces as a top-level unhandledRejection in the
webview console.

Wrap the call in try/catch and treat failure as 'unknown region',
leaving `storefrontRegionCode` null so downstream region-gated
features degrade gracefully. Also guard against an empty resolve
shape with optional chaining on `res?.regionCode`.
2026-05-25 18:28:26 +02:00
loveheaven c5384b2a6b fix: respect Android Back / Esc inside Settings sub-pages and Import-from-Folder dialog (#4286)
* fix(library): cancel Import-from-Folder dialog on Android Back / Esc

The dialog was previously relying on <Dialog>'s built-in
`native-key-down` listener to handle Back / Escape, but
`useKeyDownActions` (used here for the Enter-to-confirm shortcut)
registers its own sync listener that returns `true` on every Back
keypress, consuming the event before <Dialog> ever sees it. As a
result Android Back and Escape were silently swallowed inside this
dialog.

Wire `onCancel` so the hook actually performs the cancel itself, and
guard it (like Enter) while a folder pick is in flight to avoid
canceling mid-pick.

* fix(settings): step back to parent panel on Android Back / Esc inside sub-pages

Several settings panels render an in-place sub-view based on local
state (FontPanel -> Custom Fonts, LangPanel -> Custom Dictionaries,
IntegrationsPanel -> KOSync / WebDAV / Readwise / Hardcover / OPDS /
Send-to-Readest). Pressing Android Back (or Escape) while one of
these sub-pages was open used to close the entire Settings dialog
because only <Dialog>'s own `native-key-down` listener handled the
event.

Mount a `useKeyDownActions` hook at each parent panel, gated on the
sub-page being open, that calls the existing "go back" handler and
consumes the event. Because `dispatchSync` walks listeners LIFO, the
panel-level hook (registered after <Dialog>'s) claims Back first
while a sub-page is open; once the sub-page is closed the hook is
disabled and Back falls through to <Dialog> as before, closing the
whole Settings dialog.

This keeps all logic in the three parent panels — no changes needed
to the seven sub-page components — and a single hook in
IntegrationsPanel covers all six integrations sub-pages.
2026-05-25 16:00:33 +02:00
loveheaven ca17131f2a fix(reader): keep New Chat button visible above Android nav bar and force theme contrast (#4287)
The floating 'New Chat' button in the chat history sidebar suffered from
two issues on mobile:

1. On Android (e.g. Pixel 9 with the gesture pill) the button rendered
   underneath the system navigation indicator because its position used
   plain bottom-4 with no safe-area inset.
2. With bg-base-300 / text-base-content the pill could collapse to a
   nearly invisible solid black shape under some themes / contexts where
   text-base-content was inherited as a near-background color, hiding
   the icon and label entirely.

Fixes:
- Offset the wrapper by env(safe-area-inset-bottom) + 1rem so the button
  sits above the Android gesture pill and iOS home indicator.
- Switch the button to bg-primary / text-primary-content with shadow-md
  to guarantee strong contrast across all themes.
- Add pointer-events-none on the positioning wrapper and pointer-events-auto
  on the button itself so the floating layer never blocks list interaction.
2026-05-25 15:57:55 +02:00
Huang Xin 336a719e08 fix(library): seed custom texture store at boot so saved texture renders on first paint (#4284)
Fixes #4254. On app boot, Providers called applyBackgroundTexture
immediately after loadSettings() resolved, but the customTextureStore
was still empty — loadCustomTextures only ran later when ColorPanel
mounted or useReplicaPull seeded it during library render. The hook's
addTexture fallback re-derives the texture id from name and creates a
new entry with a different id whenever the saved id wasn't computed
from the current name (legacy imports, cross-device sync), so
applyTexture silently bailed out and no texture was mounted.

Seed customTextureStore.setTextures(settings.customTextures) in
Providers right after loadSettings() resolves — preserving the saved
ids — so applyTexture can resolve them at boot. Only custom textures
were affected; predefined textures (concrete, paper, etc.) worked
already because the lookup falls back to PREDEFINED_TEXTURES.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 18:34:10 +02:00
Huang Xin 49b171f5e5 fix(reader): restore right-column clicks and selection in dual-page mode (#4283)
* fix(reader): restore right-column clicks and selection in dual-page mode

Bumps foliate-js to readest/foliate-js@1ea2996, which stops marking
visible non-primary views as `inert` / `aria-hidden`. Adds a regression
test for the underlying visibility helper.

When a dual-page spread crosses a section boundary, the right column
lives in a non-primary view. The previous a11y sync set both `inert`
and `aria-hidden` on that view — `inert` blocked link clicks and text
selection in the visible column, and `aria-hidden` hid it from
assistive tech while sighted users could still read it. The fix drops
`inert` entirely (screen-reader swipe-next is already handled by
`aria-hidden`) and applies `aria-hidden` only to views whose bounding
rect lies outside the visible container.

Fixes #4243 (right-column links unclickable in dual-page mode).
Fixes #4259 (text selection fails when an image section is on the left).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(reader): update paginator-multiview a11y assertions for new visibility-based aria-hidden

Browser tests previously assumed every non-primary view carried both
`inert` and `aria-hidden`. The fix in the preceding commit drops `inert`
entirely and only `aria-hidden`s views that are off-screen. Update the
two assertions to:

- check that no wrapper ever has `inert`;
- require `aria-hidden="true"` only when the wrapper's bounding rect is
  fully outside the visible container, and require its absence when the
  wrapper overlaps the viewport (the regression case from #4243 / #4259).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 17:17:04 +02:00
Huang Xin 5e366018df fix(cbz): ComicInfo metadata + CBZ page count + WebDAV i18n (#4282)
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n

Closes #4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").

CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
  description / subject / identifier / published / series fields
  beyond the prior name+position pair. Series Count populates the
  canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
  from `belongsTo.series.total` in parallel to the existing
  series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
  `pagesLeft = 1` for fixed-layout books and compute it from
  `section.total - section.current`. FooterBar uses
  `FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
  (correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
  fixed-layout titles (no chapter structure) and keep
  "in chapter" for reflowable books.

WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
  prop) instead of `_(...)`. The i18next-scanner only looks for
  `_`, so ~53 strings were unreachable and shipped in English to
  every locale. Switched both components to call
  `useTranslation()` themselves; helpers that aren't React FCs
  take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
  (`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
  `UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
  reserved for the dev console. New `formatConnectError` and
  `formatSyncError` helpers in WebDAVForm translate via a switch
  where each branch is a literal `_('...')`. Same treatment for
  the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
  "Syncing {{n}} / {{total}}" with n=0 at startup so the digit
  formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
  ternary; rewrote as plural-aware key.

i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
  extensions (Next.js route folder `runtime-config.js/`, Playwright
  screenshot folder `*.test.tsx/`) and crashed with EISDIR.
  Resolved by expanding globs via `fs.globSync` and filtering to
  files only before handing to the scanner.

TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
  `failed[0]!.title` inside `_(..., options)` arguments. Replaced
  with `e instanceof Error ? e.message : String(e)` and
  `failed[0]?.title ?? ''` — also runtime-safer.

User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
  WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
  replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.

Locale translations:
- ~2400 translations applied across all 33 locales for the keys
  that were either newly extractable, freshly worded, or
  pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
  remain after the run.

Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
  runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(test): fix vitest invocation, run with 4 workers

`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:

    dotenv -e .env -e .env.test.local -- vitest -- --watch=false

The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).

Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:59:29 +02:00
Huang Xin b78daed562 feat(send): gate email-in to Plus, Pro, and Lifetime plans (#4280)
Email-in (`<user>@readest.com`) is now a paid feature. The other
Send channels — in-app /send page, mobile share-sheet, browser
extension — stay open to free users.

Three enforcement layers:

- `pages/api/send/address.ts` and `pages/api/send/senders.ts` return
  403 with `{ code: 'plan_required', plan, requiredPlans }` for free
  users. No `send_addresses` row is allocated on the blocked path.
  `pages/api/send/inbox.ts` and `pages/api/send/inbox/file.ts` are
  deliberately left open — they're shared with the file-upload and
  extension channels.

- `workers/send-email` looks up `plans.plan` after resolving the
  recipient and bounces (not silently drops) inbound mail for free
  users with a one-sentence message pointing to upgrade plus the free
  clip channels. Bounce rather than drop so a downgraded user
  understands why their mail stops landing.

- `components/settings/integrations/SendToReadestForm.tsx` reads the
  user's plan from the JWT before any API call. Free users see one
  friendly card — headline, value prop, "View plans" CTA → /user, and
  a softer line about the free alternatives — instead of address /
  senders / activity sections of disabled controls. The
  IntegrationsPanel NavigationRow stays visible so users can discover
  the feature.

Single source of truth for the entitled tier set: `EMAIL_IN_PLANS` +
`isEmailInPlan(plan)` in `src/utils/access.ts`. Mirror copies live in
the Worker (no shared import surface) — keep them in sync.

Edge cases:
- Downgraded user: existing `send_addresses` row stays. All three
  layers block; re-upgrading silently restores the same address.
- Loading flicker: `userPlan` starts as `null` so the loading skeleton
  stays up rather than briefly flashing the upgrade card for a paid
  user on a slow client.

12 new unit tests cover the gate on `/api/send/address` and
`/api/send/senders` (GET + POST blocked for free users, no Supabase
access on the blocked path, allowed for plus / pro / purchase).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:30:32 +02:00
iFocuspace db1c474e77 fix(layout): use 100vh fallback for .full-height to unbreak old Chromium WebView (#4278) 2026-05-23 10:49:02 +08:00
Amir Pourmand b8d986cbd6 fix(docker): fix Docker image latest tag and production runtime errors; add dev compose file, Codespace support, and semver release tagging (#7) (#4277)
* fix: also tag Docker image as latest on main branch push

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/bbc0f66e-7488-4d9d-976b-434588b3faa8



* fix: production Dockerfile missing patches/.env.web; add compose.dev.yaml for dev hot-reload

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/e27bba4e-b8ea-4694-b44d-6308aa778d41



* fix: add devcontainer to init submodules; add clear Dockerfile error when submodules missing

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/16d24ae0-d2e8-4523-b47e-4969dd587c50



* simplify production-stage: COPY --from=build /app /app instead of selective copies

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/356fabe4-f944-48b6-a409-41640480d52f



* fix: add CI=true to dev compose to prevent pnpm no-TTY abort

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/ddc30fef-2c76-452d-aad6-d688ca912a1a



* fix: add semver Docker image version tags on release

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/18cbc3c6-ba92-4e63-bef9-93dcf6698c21



---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
2026-05-23 10:39:15 +08:00
loveheaven 66c198e575 chore: bump tauri-plugin-webview-upgrade to c7c04ab (#4276) 2026-05-23 10:34:43 +08:00
Huang Xin 8a19c686cb ci: address code scanning scorecard alerts (#4275) 2026-05-22 20:20:10 +02:00
iFocuspace 0564b4dd48 fix(eink): restore [data-eink='true'] button rule grouping (#4273)
In #4230 the new .btn-contrast block was inserted into the middle of
the existing rule

  [data-eink='true'] button,
  [data-eink='true'] .btn {
    color: theme('colors.base-content') !important;
  }

which left [data-eink='true'] button grouped with .btn-contrast
(applying background-color/border/color: base-100) and orphaned
[data-eink='true'] .btn as its own rule. Net effect on any button
that also has class="btn" — toolbar icon buttons, popup actions, etc.:

  [data-eink='true'] button    bg=base-content, color=base-100   (specificity 0,1,1)
  [data-eink='true'] .btn      color=base-content                (specificity 0,2,0)

The .btn rule wins on color, so the button ends up with a base-content
background AND base-content text color. react-icons children render
with fill: currentColor → invisible icon on solid background → every
toolbar/popup button becomes a solid black (light mode) or solid white
(dark mode) square in e-ink mode.

Fix is the minimal regroup that #4230 was apparently trying to make:
put the new .btn-contrast block AFTER the existing eink button/.btn
selector list rather than splitting it.

Tested locally: in e-ink mode, all annotation toolbar / quick-action
popup buttons recover their icon glyphs; .btn-contrast still renders
as the intended solid-CTA in both modes.
2026-05-22 19:32:01 +02:00
loveheaven 5c82351ab9 feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings

Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers.

Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy.

* feat(webdav): add diagnostic sync history panel and document viewSettings invariant

Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass.

Sync history panel:

  * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file).

  * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars.

  * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log.

  * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters.

viewSettings invariant:

  * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake.

* fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId

Two bugs in the WebDAV sync flow surfaced during review:

1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block
   from the four credential fields the user just typed, dropping
   `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`,
   `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is
   the deviceId rotation: a disconnect + reconnect made the next sync
   look like a brand-new device, defeating the cross-device clobber
   detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a
   pure helper `buildWebDAVConnectSettings` that spreads the previous
   webdav object first so reconnect is non-destructive, matching the
   sibling pattern in KOSyncForm.

2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure
   variable `settings`, which can be stale when `pullNow → pushNow`
   fires back-to-back on book open or when the settings panel writes a
   sibling field concurrently. Read latest settings via
   `useSettingsStore.getState()` to match the pattern already used in
   `updateLastSyncedAt` and `persistWebdav`.

Adds three unit tests for the new helper, including the reconnect
preservation invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(webdav): address review observations on encodePath, pull skip, and remote GC

Three follow-ups from the review pass on top of 3f721d04. Each one was
called out as a smaller observation the reviewer noted but did not push:

* WebDAVClient.encodePath silently re-escaped literal % characters
  despite a comment claiming existing %-escapes are preserved. A caller
  that pre-encoded a space as %20 would see %20 become %2520 in the
  request URL, breaking any path that came in already escaped. Tokenise
  each segment into already-escaped %XX runs and everything-else, and
  only run encodeURIComponent on the latter. Add four unit tests
  exercising pure-unicode, pure-pre-escaped, mixed, and root-slash
  paths.

  Implementation note: two regexes are needed because a /g RegExp.test
  is stateful and would skip every other token in this map; the split
  regex has /g for the iteration, the classifier regex is anchored
  without /g for the per-token check.

* OPEN_PULL_SKIP_MS doc-comment claimed it catches the
  close-then-reopen flow, but useWebDAVSync unmounts on reader close so
  lastPulledAtRef resets to 0 — the new instance always passes the
  cooldown check on remount. The guard actually only fires on
  re-invocations of the open-book effect inside one hook lifetime
  (book-to-book navigation, double-render before hasPulledOnce flips).
  Rewrite both the constant's doc-comment and the call-site comment to
  match the real semantics.

* WebDAVSync push path doesn't DELETE the per-hash directory of a
  tombstoned book. The deletion *is* propagated through library.json
  so other devices hide the book, but storage on the WebDAV server
  grows monotonically. Add a TODO at the pushLibraryIndex call with a
  sketch of what a future garbage-collection sweep would need (a per-
  device acknowledgment field on RemoteLibraryIndex so we don't wipe
  data a peer hasn't seen the deletion for yet).

* refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm

The WebDAV settings form was nearing 1500 lines and hosted three
loosely related surfaces — credential entry, sync controls + manual
trigger, and the in-app file browser — that didn't share much state.
Reviewer flagged it as a refactor candidate; this commit does the
actual split.

* WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory
  listing, per-entry download status, the navigation handlers and the
  per-file icon / filename helpers. Reads credentials from the
  settings prop and otherwise reaches for envConfig / useLibraryStore
  / useAuth itself rather than threading them through props (matches
  how the rest of the integrations panels are wired).

* SyncHistoryPanel (new, 293 lines): the diagnostic history surface
  plus its three private helpers (SyncStatusBadge, formatSyncSummary
  Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from
  the inline definitions at the bottom of WebDAVForm — the component
  was already presentation-only and accepting the translation fn as a
  prop, so no API change.

* WebDAVForm (676 lines, down from 1456): keeps the mode switch
  (configured vs. not), the credential form, the sync sub-controls
  (Upload Book Files / Sync Strategy / Sync now button), and the
  large handleSyncNow effect — those last two are intrinsically tied
  to the settings store and would have just been pushed back up the
  prop chain by any extraction. The standalone SyncHistoryPanel and
  WebDAVBrowsePane are now mounted as siblings inside the configured
  branch.

No behavioural change — both new files run the same effects, build
the same JSX, and read/write the same store fields as before. All
existing webdav-related unit tests still pass.

Resolves the last of the reviewer's smaller observations on
3f721d04 (file length).

* fix(webdav): stream book uploads to avoid renderer OOM on large files

Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android.

Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there).

Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API.

* fix(webdav): keep Sync now state alive across Settings navigation/close

WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server.

Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page.

Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch.

* feat(webdav): cleanup mode for orphan book directories on the server

WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch.

Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class.

The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk.

Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries).

* test(webdav): cover deleteDirectory and deleteRemoteBookDir

Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:55:12 +02:00
Huang Xin f4de55e8f3 feat(send): twitter/x site rule + meta-tag fallback for stale rules (#4270)
Two layered additions to the clip-to-Readest pipeline:

* **Twitter / X article rule** — `[data-testid="twitterArticleReadView"]`
  for the body, `User-Name`/`UserAvatar-Container-*` testids for byline
  and avatar. Readability can't latch onto X's class-mangled CSS-in-JS,
  so the testid hooks are the only reliable anchors.

* **Universal meta-tag fallback** — new `META_FALLBACK` constant in
  `siteRules.ts` holding OpenGraph / Twitter Card selectors for title,
  byline, and author image. Site rules are now hints, not contracts:
  when their selectors miss (after a frontend redesign) the pipeline
  drops down to meta tags, then to Readability for content, before
  giving up. The fallback also fires when no site rule matched at all,
  so previously-unruled sites pick up byline + cover image they used
  to lack.

Wiring in `convertToEpub.ts`:
  - `extractWithSiteRule` consults `META_FALLBACK.{title,byline}` when
    rule selectors return empty.
  - The Readability branch does the same so non-ruled sites get a real
    byline/title instead of `''`.
  - `buildArticleCover` tries `og:image` / `twitter:image` between the
    rule's `authorImage` and the favicon fallback.
  - `extractHtmlTitle` prepends `og:title` before `<title>` / `<h1>`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:44:19 +02:00
Amir Pourmand 0e97206907 ci: optimize build time for Docker and CI workflows (#4263)
* ci: optimize build time for Docker and CI workflows

- Dockerfile: slim production-stage to copy only runtime artifacts
  (.next, public, node_modules, package.json, next.config.mjs),
  dropping src/, src-tauri/, patches/, packages source, etc.
- Dockerfile: add sharing=locked to pnpm store cache mount to prevent
  concurrent-build cache corruption
- docker-image.yml: pin actions/checkout to SHA (consistent with other workflows)
- docker-image.yml: switch Buildx cache from type=gha (10 GB shared limit,
  poor for multi-arch) to type=registry on GHCR (no size cap, already
  authenticated, correct per-platform caching)
- pull-request.yml: use pnpm install --frozen-lockfile --prefer-offline
- release.yml: use pnpm install --frozen-lockfile --prefer-offline
- next.config.mjs: skip redundant eslint pass during next build
  (lint already runs as a dedicated CI step)

* fix(docker): eliminate QEMU emulation, fix pnpm version mismatch, patch artifact write CVE (#4)

* fix(docker): eliminate QEMU arm64 emulation and fix pnpm version mismatch

- Fix pnpm version in Dockerfile: 10.29.3 → 11.1.1 (matches package.json)
  Prevents corepack from re-downloading pnpm 11 on every build
- Replace single QEMU job with matrix build (ubuntu-latest for amd64,
  ubuntu-24.04-arm for arm64) — eliminates ~21 min QEMU emulation overhead
- Use per-platform build cache tags (buildcache-linux-amd64 / buildcache-linux-arm64)
  to avoid cache thrashing between architectures
- Add merge job that assembles multi-arch manifest from platform digests

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): upgrade artifact actions to v7 to patch arbitrary file write vulnerability

actions/download-artifact >= 4.0.0 < 4.1.3 allows arbitrary file write
via artifact extraction. Pin both upload-artifact and download-artifact to
v7 (SHA-pinned), consistent with the rest of the repo's workflows.

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/89c298b4-8a58-4517-ac7f-2f26c86bbcd6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore(docker): tighten build context

Exclude local env, build output, credential, and tooling state files from Docker build context to reduce registry cache exposure.

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-22 18:43:33 +02:00
Huang Xin 81bd5ee6b8 fix(send): address extension review findings (#4271) 2026-05-22 17:31:04 +02:00
loveheaven 9fa7cb266c fix(migration): skip migrate20251029 silently on fresh installs (#4268)
migrate20251029 unconditionally calls copyFiles() and deleteDir() on the legacy Images/Readest/Images path. On a fresh install where that directory never existed, both calls bubble up an OS error ("os error 2" / ENOENT) which is then caught one frame up by runMigrations() and printed as an Error migrating to version 20251029 in the console. Functionally harmless (migrationVersion is still advanced afterwards), but a misleading red herring for new users and anyone debugging startup logs.

Check fs.exists(oldDir) up front and return early if absent; also guard the deleteDir call in case copyFiles partially completed and the cleanup target is gone. No behavior change on machines that actually have the legacy layout.
2026-05-22 17:06:36 +02:00
loveheaven 60203a8dc3 docs: add architecture and code-layout guides (#4265)
Add two complementary English documents under apps/readest-app/docs/ to help new contributors ramp up on the Readest codebase:

- code-layout.md: directory-level inventory of the monorepo, covering apps/readest-app, packages/, the Tauri native shell, workers, and the conventions used for stores, services, and components.

- architecture.md: high-level architecture with Mermaid diagrams. Maps the three runtimes (browser webview, Tauri Rust host, Next.js server), the dual API routers (pages/api legacy + app/api), Zustand stores, AppService / DatabaseService abstraction with its three backing implementations, foliate-js / pdfjs / simplecc / jieba integration, and cross-cutting subsystems (sync, cloud library, AI/RAG, translation, TTS, dictionaries, OPDS, Hardcover/Readwise, annotations, Send to Readest). Also documents the COOP/COEP middleware, allow_paths_in_scopes security gate, and the runtime re-config trick used for the single-image Docker deploy.

No code changes; documentation only.
2026-05-22 17:05:28 +02:00
Huang Xin 912e97cb82 feat(send): iOS share-extension picker + App Group queue + reliable host launch (#4267)
* feat(send): iOS share-extension picker + App Group queue + reliable host launch

Rework the iOS Share Extension to a Zotero-style sheet: URL preview row +
library group picker + "Save & Open". Queues each save into the shared
App Group container and best-effort launches the host app via the
Chrome-style responder-chain trick (IMP cast against
`openURL:options:completionHandler:`). The host plugin drains the queue
on `applicationDidBecomeActive`, so if the launch ever fails the article
still ingests next time Readest is opened.

A `WKScriptMessageHandler` named `readestShareBridge` lets the JS hook
post `{type:'ready'}` on mount, fixing the cold-start race when the
extension wakes the app before the React side has loaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(send): cover generator, favicon fetcher, share-extension polish, locale sync

Builds on the previous commit (iOS share-extension picker + App Group queue +
reliable host launch) with three further additions to the send-to-Readest
pipeline:

* **Cover generator** — `services/send/conversion/coverGenerator.ts` renders a
  deterministic cover image into clipped EPUBs (favicon + page title + host).
  Hooked into `buildEpub.ts` so every clip path (desktop, mobile, browser
  extension) produces the same cover for the same source.
* **Favicon fetcher** — `services/send/conversion/faviconFetcher.ts` resolves
  the best-available site icon (Open Graph image → apple-touch-icon →
  /favicon.ico), with size + format normalization. Feeds the cover generator.
* **Unified page conversion** — `convertToEpub({kind:'page', ...})` replaces
  the older `convertPageToEpub(html, url)` so the share extension, /send page,
  and browser extension share one entry point. Test: `send-convert-page-unified`.
* **Share-extension project.yml comment** — clarifies why the ShareExtension
  target carries no `.lproj` files (system bar buttons + JS-supplied "Default"
  label, no per-locale strings to wire).
* **Locale sync** — 33 translation.json files updated with new "Default",
  "Saving article…", and cover-generator strings extracted by i18next-scanner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:46:13 +02:00
Huang Xin f6dfd09d82 feat(send): browser extension that clips pages into Readest as EPUBs (#4266)
Replaces the URL-only placeholder extension with a full MV3 page-clipper
that builds a self-contained EPUB on the user's machine and uploads it
to the inbox.

- Captures the rendered DOM in a content script, then runs Readability,
  asset bundling, and EPUB build through the shared
  `convertToEpub({kind: 'page'})` pipeline inside a Chrome offscreen
  document (the SW lacks DOMParser).
- Uploads the resulting EPUB directly from the offscreen page to the
  new `POST /api/send/inbox/file` endpoint — keeps the bytes in one
  realm because `runtime.sendMessage` JSON-serialises ArrayBuffer to
  `{}` between extension contexts.
- Adds a long-lived Port + ping handshake between SW, offscreen, and
  the on-demand capture content script so neither idle-eviction nor
  load-order races can hang the popup.
- Localised popup, badge feedback, key-as-content i18n (`_('English source')`)
  with an extract script that seeds locale stubs from i18n-langs.json
  and writes a static-imports map for the runtime. All 33 locales
  fully translated.
- Server: `pages/api/send/inbox/file.ts` accepts a raw EPUB body
  (Content-Type: application/epub+zip), enforces the inbox pending cap,
  stores to the existing send-inbox R2 bucket as `kind='file'`.
  `assetBundler` now sets `credentials: 'include'` in the non-Tauri
  branch so the extension SW carries paywalled-CDN cookies.
- 47 vitest cases for the extension shell (upload, badge, auth, lazy,
  popup state machine, auth-bridge token sync) + 8 cases for the new
  server endpoint. CI's `test_web_app` invokes both via the extended
  `test:pr:web` plus a `build-browser-ext` step that catches webpack
  alias / Tauri-stub regressions.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:45:29 +02:00
Amir Pourmand 9ad43aa8b2 feat(docker): add GHCR and Docker Hub image publishing (#4250)
* Add GHCR and Docker Hub image publishing with fully runtime-configurable pull-first Docker setup (#1)

* feat: add container image publishing workflow and pull-based compose setup

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* refine docker publishing workflow and pull-first compose docs

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/23c31167-9e15-4d44-ab89-f267b8cd6304

* chore: temporarily expose docker publish run results for pr branch

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/c946a2f2-2219-4dea-a829-61b287bc4859

* fix: update pinned SHAs for setup-qemu-action and setup-buildx-action to v3 heads

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/0db6957e-476b-48e0-acf3-bee6964c3b32

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: switch all workflow action refs from SHA pins to stable version tags

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/19334b37-9b4c-45df-9c3c-81a497cef8e8

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: restore missing `with:` blocks lost during SHA-to-tag substitution

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/f204f742-5b7d-4f05-9647-03b9db86ea3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix(ci): checkout submodules for docker image workflow

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/72309e8a-6c7c-4004-902a-565f67e5c15f

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* feat(docker): support runtime client env for pulled web image

* refactor(web): serve runtime config via script endpoint

* fix(web): escape runtime config script payload

* fix: align docker runtime config with internal/public backend urls

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* chore: align published workflow tags with docs

* docs: clarify runtime config precedence and linux host mapping

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/3bbcb608-6202-4f9e-b288-5c95a259da93

* refactor: move storage/quota config from build args to runtime env

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/da9b749e-0b1c-47b9-b474-5009765b6ea6

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: load runtime config in pages router app shell

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/b375132c-8317-4c98-b437-ae48c0153e3d

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: apply biome formatting for runtime config quota helpers

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/778f5d75-884e-401b-b7cb-4ab40bc64a11

Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>
Co-authored-by: Amir Pourmand <pourmand1376@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: S3_PUBLIC_ENDPOINT, _document.tsx for beforeInteractive, runtimeConfig fallbacks, README port (#2)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4f92f818-c008-4caa-9684-d530500b5fb2

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

* fix: reformat runtimeConfig.ts to satisfy biome formatter (#3)

Agent-Logs-Url: https://github.com/pourmand1376/readest/sessions/4eea77f4-67ab-4428-b59e-0b21be988037

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pourmand1376 <32064808+pourmand1376@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-21 19:57:35 +02:00
loveheaven ae81cd0151 feat(annotator): support global highlights that fan out across all matching positions (#4257)
Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ.

- types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back.

- db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match.

- annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global.

- sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required.
2026-05-21 18:59:07 +02:00
Huang Xin 62b5ed8138 feat(send): handle shared URLs from system share sheets (iOS + Android) (#4256)
Users can now tap "Share → Readest" in Safari, Chrome, or any other
browser on iOS / Android and the article URL flows through the same
clip-and-import pipeline the in-app "From Web URL" entry uses.

Android

`MainActivity.handleIncomingIntent` already routed file shares via
`ACTION_SEND` + `EXTRA_STREAM`. Extend it to also pick up URL shares
via `ACTION_SEND` + `EXTRA_TEXT`: parse the first http(s) token out of
the text payload and dispatch it on the existing `shared-intent` event
channel. No new event channel needed — `useAppUrlIngress` already
listens and re-broadcasts as `app-incoming-url`.

The existing `<intent-filter>` for `ACTION_SEND` with `*/*` MIME type
already accepts `text/plain` from browsers — no manifest change
required.

iOS

`gen/apple/` gains a new ShareExtension target. The extension's
`ShareViewController` extracts a URL from `NSExtensionContext.inputItems`
(prefers `public.url`, falls back to first http(s) token in
`public.plain-text`) and forwards it to the main app as
`readest://clip?url=<encoded>` via the responder-chain `openURL:`
selector — the standard share-extension trick used by Pocket,
Instapaper, Matter, etc.

`project.yml` adds the ShareExtension target and switches the main app's
Info.plist / entitlements references to `INFOPLIST_FILE` /
`CODE_SIGN_ENTITLEMENTS` build settings instead of xcodegen's `info:` /
`entitlements:` blocks. That way the hand-tuned `Readest_iOS/Info.plist`
(CFBundleDocumentTypes, UTExportedTypeDeclarations, locales,
CFBundleURLTypes for readest://, applesignin, associated-domains for
Universal Links) is treated as an opaque input — xcodegen won't
regenerate it.

JS

New `useClipUrlIngress` hook subscribes to `app-incoming-url`,
unwraps `readest://clip?url=<encoded>` into the inner URL (the iOS
forwarding path), filters out file URIs and annotation deep links,
and runs each remaining http(s) URL through `clip_url` →
`convertToEpubWithWorker` → `ingestFile` — the same path `/send` uses.

Mounted alongside `useOpenWithBooks` and `useOpenAnnotationLink` in
both `app/library/page.tsx` and `app/reader/page.tsx` so shares
arriving while the user is reading still process.

Notes

- The PR targets `feat/send-clip-mobile` (PR #4252) since the share
  pipeline depends on `clip_url` being available on mobile.
- iOS Share Extension built locally via xcodegen; the regenerated
  pbxproj is tracked because gen/apple is gitignored.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:50:33 +02:00
Huang Xin 17749f7cc7 feat(send): mobile URL clipping via native-bridge plugin (#4252)
iOS and Android now run the same Web-URL clip flow as desktop. Paste
an article URL, the native side opens a full-screen WKWebView /
WebView with the same Chrome UA + fingerprint mask + "Saving to
Readest" overlay as the desktop hidden window, waits for load +
settle, captures `document.documentElement.outerHTML` via the
platform's `evaluateJavaScript`, and returns it through the existing
`convertToEpub` pipeline.

JS surface stays `invoke('clip_url', { url, options })` — no changes
in `library/page.tsx` or `send/page.tsx`. The platform branch lives
entirely in `clip_url.rs`.

Why not Tauri's `Window::add_child`

`add_child` is gated `#[cfg(any(test, all(desktop, feature =
"unstable")))]` in tauri 2.10. No public API for attaching a second
webview to the main window on mobile, so the clip flow can't be a
`#[cfg(mobile)]` branch of the existing `WebviewWindowBuilder` shape
— it needs native code. Extend `tauri-plugin-native-bridge` rather
than create a separate plugin: the Swift / Kotlin scaffolding +
Tauri IPC are already there.

Layout

- `src-tauri/src/clip_url.rs` — desktop branch unchanged; new
  `#[cfg(mobile)]` `clip_url` command routes through
  `app.native_bridge().clip_url(request)`. Shared `ClipOptions`
  struct exposes its fields `pub` so the mobile branch can map into
  the plugin's `ClipUrlRequest`.
- `plugins/tauri-plugin-native-bridge/src/models.rs` — `ClipUrlRequest`
  + `ClipUrlResponse` mirroring `ClipOptions` field-for-field so the
  payload travels untouched from JS through to Swift/Kotlin.
- `plugins/tauri-plugin-native-bridge/src/{desktop,mobile}.rs` — desktop
  returns an error (desktop has its own path); mobile dispatches via
  `run_mobile_plugin("clip_url", payload)`.
- `ios/Sources/ClipUrlController.swift` — `UIViewController` hosting
  `WKWebView` with the loading overlay drawn as native UIKit views
  (not an injected user script, so the page's own hydration can't
  wipe the spinner). 30 s hard timeout + 3 s settle window after
  `didFinish`, same as desktop. Fingerprint mask injected as
  `WKUserScript` at `.atDocumentStart`.
- `android/src/main/java/ClipUrlController.kt` — full-screen Dialog
  hosting a `WebView`, mirrors the iOS controller's behaviour. JSON-
  decodes the `evaluateJavascript` callback (raw return value is a
  JSON-encoded string).
- `NativeBridgePlugin.{swift,kt}` — new `clip_url` method that parses
  args via `invoke.parseArgs`, presents the controller, resolves the
  invoke with `{ html }` on success or `invoke.reject` on failure.
  Same rejection vocabulary as desktop (`"Invalid URL"`, `"Page took
  too long to load"`, etc.) so the calling JS doesn't need a
  platform branch.
- `build.rs` — adds `clip_url` to the plugin's `COMMANDS` array.

Notes

- The Swift overlay reserves the iOS safe-area-edge-to-edge so notch /
  Dynamic Island devices don't see the underlying app peek through
  during the brief capture window.
- The Android overlay's spinner tint follows the foreground theme
  colour at 85 % alpha — same idea as the iOS controller.
- `WKWebView`'s JS keeps running while the controller is presented;
  no off-screen / `isHidden` trick that would let iOS throttle the
  page mid-capture.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:00:53 +02:00
loveheaven dabdcdcc53 fix(macos): fix traffic lights position on macOS 26 (#4247)
* fix(macos): place traffic lights via Tauri trafficLightPosition

Replaces the cocoa private-API positioning that drove traffic light placement through IPC with Tauri's supported trafficLightPosition window option, which routes through wry's macOS API and stays correct across versions including macOS 26 (Tahoe).

Position is now declared once at window creation: WebviewWindowBuilder.traffic_light_position in src-tauri/src/lib.rs for the initial main window, and trafficLightPosition on new WebviewWindow(...) in utils/nav.ts for reader windows and the recreated main window. The reader path mattered — those windows used to rely on the cocoa hack to place buttons after the on_window_ready hook fired, so any path that bypassed it left the buttons in AppKit's overlay default position (off-screen on macOS 26 until a resize).

The IPC surface narrows accordingly. set_traffic_lights now takes only visible: position is no longer a parameter and the WINDOW_CONTROL_PAD_X/Y static muts go away; setTrafficLightVisibility drops its position arg in trafficLightStore; useTrafficLight and HeaderBar drop their hard-coded { x: 10, y: 20 } magic numbers. position_traffic_lights stops touching the per-button NSWindowButton frames entirely and only collapses or restores the title-bar container view to hide / show buttons during reader chrome auto-hide. A short-circuit on the no-op transition keeps the cocoa setFrame from racing AppKit's own traffic-light tracking on every IPC call.

useTrafficLight stays — it still owns full-screen visibility synchronisation, the auto-hide visibility toggle, and feeds isTrafficLightVisible to the self-drawn <WindowButtons /> in the auth, library, OPDS, reader-sidebar, and user headers. None of those have an equivalent in the new declarative API. Only its 'where do the buttons sit' responsibility was moved out.

A single named constant TRAFFIC_LIGHT_RESTORE_Y_INSET is left behind in traffic_light.rs, used solely by the visible: false → true restore path to recompute the title-bar container height. It must agree with the y component of the two declarative trafficLightPosition values; a doc comment makes that contract explicit. Caching each window's natural title-bar height before the first collapse would let us delete the constant entirely, but the per-window state machine that requires is not worth the win for a single number.

y is tuned by eye to 24 to vertically center the buttons inside readest's ~48px header bar on macOS 26.1.

* fix(macos): center traffic lights from live AppKit offset, no version check

Restores the pre-PR cocoa-driven positioning that worked on macOS 15
while keeping the macOS 26 fix this PR was originally about: the
plugin owns `position_traffic_lights`, which now sizes the title-bar
container *and* sets each window button's frame.origin on every
on_window_ready / resize / theme-change / full-screen-exit event. Tao's
runtime `inset_traffic_lights` never fires (we never declare
`trafficLightPosition` or call `set_traffic_light_position`), so there
is no second code path fighting us on drawRect.

The y inset that visually centers the close button is computed at
runtime as

    y = (header_height - button_height) / 2 + button_origin_y

where `button_origin_y` is the close button's natural rest position
inside the title-bar container. Apple shifted that rest position by
~2pt on macOS Tahoe (26), so the same formula yields y=22 on macOS 15.6
and y=24 on macOS 26.1 with a 48px header — no `NSProcessInfo` lookup
and no hardcoded per-OS offset. The natural origin.y is read once and
cached via `OnceLock` so any post-resize autoresize that AppKit might
apply doesn't feed back into the centering math.

Frontend plumbing: `set_traffic_lights` IPC now carries `headerHeight`;
the zustand store remembers it across visibility toggles; the
`useTrafficLight` hook accepts a header ref, mirrors `ref.current`
into local state (so the effect re-runs when LibraryHeader's
conditional render flips the ref from null to the live node), measures
the border-box height on mount, and observes via ResizeObserver to
re-push on responsive breakpoint / safe-area changes. LibraryHeader,
sidebar Header, OPDS Navigation, and the reader HeaderBar each pass
their own ref so y is computed against the chrome each page actually
renders.

Library header is normalised to h-[44px] desktop to match the reader's
h-11 and drops the `-2px` macOS marginTop workaround, since the runtime
centering removes the need for it.

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
2026-05-21 11:52:43 +02:00
Huang Xin 1a2e43e659 chore(worktree): copy src-tauri/gen/apple per-worktree so iOS builds the worktree (#4251)
Worktrees set up by `pnpm worktree:new` symlinked `src-tauri/gen/apple` back
to the bare repo. The Xcode "Run Script" build phase
(`pnpm tauri ios xcode-script`) walks up from `Readest.xcodeproj` to find
`Cargo.toml`, but the symlink target resolves to the bare repo's
`src-tauri` — so `pnpm tauri ios dev` from a worktree silently built the
bare repo's Rust source, not the worktree's.

Switch to a filtered copy. `project.yml` references `../../src` and the
xcode-script walks up to `../../Cargo.toml`; with the project copied into
the worktree, both paths resolve to the worktree's source. Mirrors what the
script already does for Android (`tauri android init` per worktree).

Skipped on the copy:

- `build/` (~300 MB of Xcode derived output)
- `Externals/<arch>/{debug,release}/` (Rust static libs — rebuilt from the
  worktree's `src-tauri` on first build; the `target/` symlink the script
  already sets up keeps the Rust object cache shared)
- per-user Xcode state (`xcuserdata`, `*.xcuserstate`)
- `Pods/` (defensive; Readest's iOS uses SPM, not Cocoapods)

Result: ~2.7 MB copy, ~30 ms locally, no `pod install`, no
`tauri ios init`. Signing config, scheme, asset catalogs and Tauri's
generated Swift glue all preserved verbatim from the bare repo.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 11:13:28 +02:00
dependabot[bot] b493cf7908 chore(deps): bump the github-actions group with 4 updates (#4249) 2026-05-21 12:52:26 +08:00
Huang Xin a1279a65ce feat(send): clip web URLs into self-contained EPUBs via Tauri webview (#4241)
Builds the URL-clipping path of the "Send to Readest" feature: paste a
link, the renderer ingests the rendered page, and a self-contained EPUB
lands in the library. No server proxy, no external CDN refs left in the
EPUB once it's saved.

Architecture

- New Rust `clip_url` command spawns a hidden Tauri WebviewWindow at the
  target URL with a real Chrome UA + WebKit fingerprint mask, so TLS-
  fingerprint and JS-challenge walls (Cloudflare, Medium, X, WeChat MP)
  resolve naturally instead of bouncing the server proxy.
- Capture transport is URL-payload navigation to a one-shot
  127.0.0.1:RANDOM_PORT/clip/{token}?d={url-safe-base64} listener.
  Top-level navigation isn't governed by CSP connect-src / form-action /
  WebKit Private Network Access — the four earlier transports
  (fetch, <form>, custom URI scheme, window.name) were each blocked by
  one of those.
- Page-to-EPUB bundler (`assetBundler`) walks <img>/<picture> with
  src → data-src → data-original → data-srcset → srcset fallback so lazy-
  loading sites don't ship a 60px LQIP; fetches assets in parallel with a
  per-asset timeout + per-asset/total caps; failed images degrade to alt-
  text placeholders. A per-site rules table (seeded with WeChat MP) + a
  selector fallback catches articles Readability misextracts. Builder
  prepends the article <h1> + byline so the EPUB has a proper opening.
- Nested EPUB TOC built from h1–h6.

UI surfaces

- "From Web URL" entry in the library Import menu, gated to Tauri; web
  build hides the URL field and points at the browser extension.
- `ImportFromUrlDialog` with auto-height (overrides Dialog's `sm:h-[65%]`
  default) and a dim placeholder for the URL field.
- Clip webview window styled to match Readest's main window — macOS
  decorations + overlay title bar; other desktops decorationless with a
  drop shadow; native background + in-page loading overlay pick up the
  caller's `themeCode.bg`/`fg` so light/dark/eink/custom themes all
  render correctly. Title localised, all five overlay/title strings
  translated across 33 locales.

Notes

- Gates the macOS traffic-light positioner to main/reader-* windows so
  the decorationless clip window no longer null-derefs in
  `position_traffic_lights`.
- Stricter validation across the path: schemes restricted to http/https,
  hex-color parsing rejects malformed values, server endpoint returns
  400 on missing/invalid base64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 19:48:09 +02:00
loveheaven 3825f355a7 fix(sel): clamp declared fontSize when it disagrees with rendered height (#4244)
The macOS system-dictionary HUD samples the underlying paragraph's
typography via getRangeTextStyleInWebview so AppKit can re-draw the
small label using the same font / size as the page text. The sampler
trusted getComputedStyle().fontSize directly, which works for the
typical EPUB inline box but breaks badly on pdf.js text layers: each
glyph span carries an intrinsic font-size that reflects the document's
unit-em size before transform: scale(...) shrinks it back to page-
coordinate pixels, so the value can be many times larger than the
on-screen glyph. Forwarded as-is to NSFont, that gives AppKit a giant
attributed string and the yellow highlight rectangle behind the HUD
ends up engulfing neighbouring paragraphs while the laid-out text
overflows off-screen.

Cross-check the declared size against range.getBoundingClientRect().
height as a sanity bound. When the declared value exceeds the inline
box height by more than 30 %, fall back to renderedHeight * 0.85
(roughly the cap-height-to-1.2-line-height ratio) so PDF lookups
converge on a sane scale; otherwise keep the declared value untouched
so normal EPUB body text is unaffected.
2026-05-20 18:14:15 +02:00
loveheaven 5ac8564e41 feat(library): add Import from Folder dialog with format/size filters (#4229)
* feat(library): add Import from Folder dialog with format/size filters

Replaces the silent "import every supported file recursively" behaviour of the directory import menu item with an explicit dialog that lets users pick which formats to include, set a minimum file size, and choose between mirroring subfolders as nested groups (legacy behaviour) or flattening every match into the current library view.

The folder, the chosen Folder Structure radio, the ticked File Formats and the File Size threshold are all persisted in localStorage so re-opening the dialog seeds every field with the user's last choice. Cancelling the dialog does not write to storage so an aborted pick won't pollute the next session.

Also hides the native number-input spinner via a small .no-spinner utility in globals.css; on macOS WebKit the spin buttons were drawing over the rounded input border and looked broken. The KB suffix now lives inside the input's bordered shell instead of beside it.

Two correctness fixes the dialog flow exposed:

* The library importer + ingestService now treat groupId as a tri-state — undefined means "don't touch the existing group", '' means "explicitly the library root", any other string means a specific group. Previously a falsy check in both layers conflated '' with undefined, so re-importing a deduped book under flatten mode silently kept its stale groupId/groupName from the prior keep-as-groups run, making the book reappear in the old subfolder group instead of moving into the library root. New regression tests in ingest-service.test.ts cover both the empty-string case and the omitted case.

* Imports of arbitrary user paths (e.g. ~/Downloads) now go through a new allow_paths_in_scopes Tauri command that extends both fs_scope and asset_protocol_scope. The dialog plugin only auto-grants fs_scope, so reads through the asset protocol (RemoteFile / convertFileSrc) used to fail with "asset protocol not configured to allow the path". The shim is invoked after every selectFiles / selectDirectory call and once more at the start of runFolderImport so localStorage-restored paths are also covered. Granted scopes persist across restarts via tauri_plugin_persisted_scope.

* fixup(library): harden Import-from-Folder scope grant + RTL/dialog polish

Three review fixes on top of the Import-from-Folder feature:

* lib.rs: refuse to extend asset_protocol_scope for paths not already
  in fs_scope. Without this gate, any frontend code (XSS via book
  content, OPDS HTML, dictionary lookups, or a compromised dependency)
  could call allow_paths_in_scopes with '/' or '~/.ssh' and gain
  persistent read access to arbitrary user files via the asset
  protocol — the grant survives restarts thanks to
  tauri_plugin_persisted_scope. Mirrors the defensive check in
  dir_scanner.rs.

* ImportFromFolderDialog.tsx: migrate from a custom ModalPortal chassis
  to the project's shared <Dialog> primitive so eink mode auto-removes
  shadows, mobile gets the bottom-sheet treatment, RTL direction is
  applied, and focus management is correct.

* ImportFromFolderDialog.tsx: swap directional Tailwind utilities for
  the logical equivalents (text-start, ps-/pe-, rounded-s-, text-end)
  per DESIGN.md §2.8 — Arabic/Hebrew users were getting a mirrored
  number-input row with the KB suffix on the wrong side.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(library): translate Import-from-Folder dialog strings across 33 locales

Translates the 13 new strings introduced with the Import-from-Folder
dialog (folder picker label, format-filter section, size-threshold
input, folder-structure radios, OK button, empty-result toast). All
33 supported locales — including RTL fa/he/ar — are now complete; no
__STRING_NOT_TRANSLATED__ placeholders remain in the catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:51:53 +02:00
Huang Xin ded64159b6 fix(send): library-clobber + perf: lazy-load conversion deps (#4238)
* perf(send): dynamic-import the conversion fallback so /library stays lean

conversionWorker.ts value-imported convertToEpub for the no-Worker
fallback path. That pulled mammoth, @mozilla/readability, DOMPurify and
@zip.js/zip.js into the main bundle — eagerly loaded on /library via
useInboxDrainer's static import of conversionWorker.

Switch the fallback to `await import('./convertToEpub')`. The worker
entry still value-imports convertToEpub for its own chunk; the
main-thread fallback only loads the heavy deps when Workers are actually
unavailable or fail.

Measured on the production web build:
- before: /library eagerly loads the 634KB conversion chunk
- after:  the 634KB chunk + its two ~627KB duplicates are all lazy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): never clobber the library when /send writes before it has loaded

The /send page (and the inbox drainer when it races the library page's
load) called `useLibraryStore.updateBooks(envConfig, [book])` while the
store still held the empty initial library — `libraryLoaded: false`. The
merge ran against `[]`, so `saveLibraryBooks` persisted just the new
book as the *entire* library and sync pushed the clobbered copy to every
device.

Two-layer fix:

1. Harden `updateBooks`: if `libraryLoaded` is false, load the real
   library from disk first, then merge — `updateBooks` is now self-
   protecting against any future caller that forgets the load step.

2. Gate `useInboxDrainer` on `libraryLoaded`. The hook now subscribes to
   the flag and starts draining the moment the library finishes loading,
   instead of running the first pass against an empty in-memory copy.

Adds a regression test that fails without the store change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:29:46 +02:00
Huang Xin ff4c03919b refactor(alert): stack title above actions row to fix narrow-width layout (#4239)
Confirm Deletion (and the three other Alert callsites — clear
annotations, delete files, book-detail delete) used to try to keep
the icon/title/message and the Cancel/Confirm buttons in a single
row. At narrow widths the row would flex-wrap and produce a cramped
two-column shape with stacked buttons next to wrapped text — the
case shown in the original PR thread's third screenshot.

Rework the layout to always stack:

* outer container is now `flex flex-col gap-3` instead of toggling
  between flex-row at sm+ and flex-col below.
* top block: icon + title/message, items-start (icon nudged with
  `mt-0.5` so it baselines with the title).
* bottom block: `flex items-center justify-end gap-2` — Cancel +
  Confirm always right-aligned on their own row.
* Drop the daisyUI `alert` class. Its `display: grid` +
  `justify-items: center` was collapsing the actions row to content
  width and pulling it toward centre, which defeated `justify-end`
  the first time around. The styles I actually wanted (`bg-base-300
  rounded-lg p-4 shadow-2xl`) were already explicit.
* Replace the chain of viewport-relative max-widths with the more
  conventional `max-w-md sm:max-w-lg md:max-w-xl` cap so the
  capsule doesn't grow without bound on big monitors.
* Drop the `text-center` flip — text stays left-aligned at every
  width, which matches the rest of the app.

Color theme unchanged: blue `stroke-info` icon, `bg-base-300`
surface, `btn-neutral` Cancel, `btn-warning` Confirm, `btn-sm`
sizing. `useKeyDownActions` keyboard binding and `role='alert'`
preserved. No callsite changes — the four consumers keep the same
props.

Verified visually at 1400 / 900 / 520 / 500 px viewports via
`pnpm dev-web`; `pnpm test` (4389 passed) and `pnpm lint` (tsgo
+ biome) clean.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:16:36 +02:00
loveheaven d943a1c146 fix(library): clear nested-folder groups when deleting from bookshelf (#4226)
* fix(library): clear nested-folder groups when deleting from bookshelf

Deleting a group from the bookshelf right-click menu used to leave the group on screen whenever the import had any sub-directories. The cause: getBooksToDelete matched only `book.groupId === id`, but the bookshelf renders a top-level group with id = md5("MyDir") while books imported from a sub-folder carry groupId = md5("MyDir/sub"). Sub-folder books never got marked for deletion, refreshGroups re-built the parent group from their groupName on the next render, and the user saw an undeletable folder.

Fix: when an id resolves to a known group via getGroupName, also collect every book whose groupName equals that path or starts with `${path}/`. Hash-based dedup keeps a book from being queued twice when both rules match. Single-book deletes and flat-folder group deletes are unaffected.

* refactor: expand group selections into book hashes at intake

Address review feedback on #4226: instead of re-deriving which books
belong to a group inside the deletion path with a path-prefix sweep,
resolve group ids into their constituent book hashes upstream where the
selection enters the deletion pipeline.

* New helper `expandBookshelfSelection(ids, items)` in libraryUtils:
  group ids resolve to every (non-soft-deleted) book in the rendered
  rollup; standalone book hashes pass through. Tested in isolation.
* `Bookshelf.deleteSelectedBooks` runs select-mode picks through the
  helper before populating `bookIdsToDelete`.
* `BookshelfItem` right-click group delete dispatches the
  constituent hashes from `group.books` directly, so the receiver
  is a simple pass-through.
* `getBooksToDelete` collapses to a flat hash lookup — no prefix
  sweep, no `getGroupName` call in the deletion path, no dedup set.

The nested-folder fix still holds because `generateBookshelfItems`
already rolls "MyDir/sub" books into the top-level "MyDir" group;
expanding via the rendered `group.books` picks them up automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:31:39 +02:00
Huang Xin 7230981288 feat(send): auto-seed the allowlist with the user's account email (#4237)
The Email Worker rejects mail from senders that are not in the user's
approved-sender allowlist. With an empty allowlist the very first send
("email it to yourself") bounces with an approval-pending notice, which
is the wrong first impression for the feature.

Seed the caller's verified account email as `approved` the moment we
lazily create the user's send_addresses row. Best-effort: address
creation still succeeds if the seed insert fails (idempotent via the
existing UNIQUE (user_id, email) constraint).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 06:29:01 +02:00
Huang Xin a30efe49c1 fix(send): make recent-activity status labels translatable (#4236)
The labels (Added to your library / Waiting to be processed /
Processing… / Failed) went through `_(activityStatusLabel(item.status))`,
a dynamic key the i18next-scanner cannot extract — so non-English locales
rendered them in English. Inline the four literal `_()` calls into the
JSX so the scanner picks them up.

Translates the two missing keys in all 33 non-English locales. Also
sweeps three pre-existing untranslated System Dictionary keys that were
introduced in #4219.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 05:32:53 +02:00
Huang Xin 0b18de0581 feat(send): Send to Readest — multi-channel capture into your library (#4230)
* feat(send): Send to Readest — multi-channel capture into your library

A Send-to-Kindle equivalent: email, web-upload, share, or one-click
capture books and articles into the cloud library; they sync to every
device.

Architecture (client-side processing): out-of-app channels drop a raw
payload into a per-user send_inbox; Readest clients drain it through one
shared ingestService.ingestFile(). The server never parses or converts.

- ingestService.ingestFile() — channel-agnostic import orchestration
  extracted from library/page.tsx (DI-based, forceUpload support).
- send_addresses / send_allowed_senders / send_inbox tables + RLS + 4
  SECURITY DEFINER claim/lease RPCs (migration 012_send_to_readest.sql).
- Conversion subsystem (DOCX/RTF/HTML/article/TXT -> EPUB) in a Web Worker.
- send-email Cloudflare Email Worker; inbox-drainer controller +
  useInboxDrainer hook; /api/send/* routes.
- Send to Readest settings panel: inbound address, approved-sender
  allowlist, recent activity, per-device drain toggle.
- /send web page (file drop + article URL) + SSRF-guarded fetch-url proxy.
- OS-shared files routed through ingestFile; Manifest V3 browser extension.

Security: inbox state changes only via SECURITY DEFINER RPCs (clients get
SELECT-only on send_inbox); approved-sender allowlist gates email;
SSRF guard on the one server-side URL fetch; inbox payload signed URLs
authorize against send_inbox.user_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: run format:check in the pre-push hook

Biome format checking is fast (~0.4s), so gate pushes on it too — catches
mis-formatted files that bypassed the staged-only pre-commit hook before
they reach CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(send): address CodeQL security findings

- ReDoS (senders.ts): the email regex had ambiguous quantifiers around
  the literal dot. Rewrote it linear-time (domain labels exclude '.')
  and cap the input at 254 chars.
- XSS (convertToEpub.ts): run untrusted HTML through DOMPurify
  (sanitizeForParsing — keeps document structure) before DOMParser, so
  title extraction and Readability never parse executable markup.
- SSRF (fetch-url.ts): harden the host guard — block bare single-label
  hostnames, IPv4-mapped IPv6, CGNAT/benchmark/multicast ranges, and the
  unspecified address. DNS rebinding stays a documented residual risk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:06:52 +02:00
Huang Xin 97221b8d23 fix(ios): suppress native text-selection menu over annotation tools (#4231)
On iOS the system text-selection menu (Copy / Look Up / Translate /
Share) appeared on top of Readest's annotation toolbar. The previous
workaround removed and re-added the selection range on a timer
(makeSelectionOnIOS) to shake the menu off — flaky on iOS 16 and on the
first long-press of a word.

Suppress the menu natively instead, in the native-bridge iOS plugin.
ContextMenuSuppressor swizzles WKContentView so non-editable web
selections produce an empty menu that is never presented:

  * editMenuInteraction(_:menuForConfiguration:suggestedActions:) — the
    UIEditMenuInteraction delegate WebKit uses to build the menu on
    iOS 16+ (the menu users actually see on modern iOS).
  * presentEditMenu(with:) — a present-time backstop.
  * canPerformAction(_:withSender:) — the legacy UIMenuController gate
    for iOS 15 and earlier.

Editable HTML fields keep their native menu (Paste / Select All still
work) via a cut:/paste: probe. Text selection and drag handles are
unaffected, so the annotation toolbar still triggers.

With suppression handled natively, makeSelectionOnIOS is removed and iOS
selections take the same path as desktop.

Closes #4218

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 19:49:58 +02:00
Huang Xin 088f690c35 ci(release): use cargo tauri CLI for Linux bundler (#4225)
The release workflow installs the Rust-based tauri-cli from the
feat/truly-portable-appimage branch for Linux builds, but the
tauri-action step had no tauriScript input. Without it, tauri-action
falls back to the npm @tauri-apps/cli, so the custom truly-portable
AppImage bundler was never actually used.

Set tauriScript to `cargo tauri` for the Linux matrix entries so the
just-installed Rust CLI is used. macOS/Windows resolve to an empty
string and keep using the npm CLI as before.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:02:26 +02:00
Huang Xin d25c41ee89 chore(security): address code scanning findings (#4224) 2026-05-19 09:01:07 +02:00
Huang Xin fe41c42ec5 chore: switch code formatter from Prettier to Biome (#4223)
Replace Prettier with Biome for formatting JS/TS/JSX/CSS/JSON. The CI
format check drops from ~23s to ~0.4s.

- Unify config into a single root biome.json (formatter + linter); the
  former apps/readest-app/biome.json was linter-only
- Mirror the old .prettierrc.json style: 100 line width, 2-space indent,
  LF, single quotes, trailing commas
- Enable the CSS tailwindDirectives parser for @apply in globals.css
- Convert // prettier-ignore comments to // biome-ignore format:
- Root scripts and lint-staged now run biome; apps/readest-app lint runs
  `biome lint` (lint-only) so formatting stays a separate CI step
- Drop prettier + prettier-plugin-tailwindcss dependencies

Markdown/YAML are no longer format-checked (Biome does not format them)
and Tailwind class sorting is no longer enforced.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:13:36 +02:00
loveheaven 05da6bdf43 feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
2026-05-19 07:03:52 +02:00
Huang Xin d35d2002c4 chore(security): add Scorecard workflow for supply-chain security (#4221)
* chore(security): add Scorecard workflow for supply-chain security

* chore: ignore prettier on .github
2026-05-19 04:59:36 +02:00
Huang Xin 5688687011 perf(ci): cache Playwright browsers and apt packages in PR checks (#4215)
* ci(e2e): cache Playwright browsers and apt packages

- cache `~/.cache/ms-playwright` keyed on the lockfile; on a hit only
  the OS deps are installed, skipping the browser download
- cache apt archives in test_web_app, matching the rust/tauri jobs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: enable Turbopack persistent cache and key it for cross-PR reuse

Enable `experimental.turbopackFileSystemCacheForBuild` so `next build`
persists a real Turbopack cache (~640 MB at `.next/cache/turbopack`),
instead of the ~340 KB of metadata the previous `.next/cache` cache
held. Dev caching is already on by default in Next 16.1+.

Redesign the cache keys so they actually pay off:

- drop `${{ github.sha }}` from the key — it made every commit a unique
  entry that no other PR could exact-hit. The key is now
  `turbo-<mode>-<target>-<os>-<lockfile-hash>`, deterministic across
  branches, so every PR restores the same entry (in practice the one
  `main` last saved — the only cache sibling PRs can all see).
- `build_web_app` (`next build`) caches `.next/cache`;
  `build_tauri_app` (`next dev`) caches `.next/dev/cache` — `next dev`'s
  Turbopack cache lives in a different directory.
- drop the Next.js cache step from `test_web_app`; it runs no build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:12:26 +02:00
Huang Xin 28a7785e5d test(e2e): add a Playwright web e2e lane (reading & annotation flows) (#4214)
* feat(e2e): add Playwright web e2e lane

Adds a web-layer end-to-end suite that drives the Next.js web build
(`pnpm dev-web`) in a real browser, complementing the existing
WebdriverIO suite that drives the Tauri shell.

- playwright.config.ts: single Chromium project, auto-starts dev-web
- e2e/pages: BasePage/LibraryPage/ReaderPage page objects
- e2e/fixtures/base.ts: suppresses demo-book auto-import for a
  deterministic empty library
- e2e/tests: library shell + search, book import, reader open +
  pagination smoke specs
- e2e/fixtures/books: synthetic sample book for import tests
- scripts: test:e2e:web, test:e2e:web:ui, test:e2e:web:report

Tests run unauthenticated against isolated browser contexts;
authenticated/sync flows are out of scope until a test account is
provisioned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): cover reading and annotation flows

Expands the Playwright web e2e lane beyond library/import smoke tests to
exercise the major reading and annotation features against the real
sample-alice.epub fixture (src/__tests__/fixtures/data/).

Reading (reading.spec.ts): open + page turn, TOC chapter navigation,
in-book search, font-size change via the settings dialog, bookmark
toggle.

Annotation (annotation.spec.ts): selection popup, create highlight,
change highlight color, add a note, delete an annotation.

- ReaderPage POM gains sidebar/TOC, search, settings, bookmark and
  annotation actions; text selection is driven inside the section
  iframe (synthetic drags do not produce a selection through nested
  paginated foliate iframes)
- openBook fixture imports and opens a book so specs skip boilerplate
- books.ts centralises fixture book paths
- replaces the old reader.spec.ts smoke

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(e2e): add headed run script and always write HTML report

- test:e2e:web:headed runs the suite in a visible browser, one test at
  a time, with traces captured
- the HTML reporter now runs for local runs too, so every run writes
  playwright-report/ for test:e2e:web:report to open

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): fix headed-run flakes in reading and annotation specs

The headed run (slower rendering) surfaced two races that the headless
run happened to pass:

- TOC navigation read reading progress before the section's async
  progress update landed — now polls with expect.poll.
- visibleSectionFrame required a paragraph fully inside the viewport,
  which intermittently matched nothing — now accepts any paragraph
  intersecting the viewport and tolerates frames detaching mid-navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci: run the Playwright web e2e suite in test_web_app

Adds `pnpm test:e2e:web` to the test_web_app job, after the unit/browser
tests. The job already installs the Chromium browser, and `.env.web` is
committed so the auto-started `pnpm dev-web` server has its config. On
failure the HTML report is uploaded as an artifact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): exclude e2e specs from the vitest run

vitest's default glob matches `*.spec.ts`, so it picked up the new
Playwright `e2e/tests/*.spec.ts` files and crashed. Exclude `e2e/`
from vitest — those specs run via `pnpm test:e2e:web`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(e2e): run the web e2e suite against a production build

`next dev` renders a full-screen error overlay when the app emits its
`next-view-transitions` "Transition was aborted" unhandled rejection,
and the overlay intercepts pointer events — making the suite flaky on
CI. CI now builds the web app (`pnpm build-web`) and the Playwright
webServer serves it via `pnpm start-web`; local runs still use
`pnpm dev-web`. Verified: 14/14 pass against the production build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* ci(e2e): run the web e2e suite in the build_web_app job

build_web_app already runs `pnpm build-web`, so the e2e suite belongs
there — it reuses that build (the CI Playwright webServer serves it via
`pnpm start-web`) instead of building a second time in test_web_app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(e2e): run the web e2e suite with 4 workers

Specs are isolated (a fresh browser context per test), so they are
safe to parallelize. `test:e2e:web:headed` keeps --workers=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:22:17 +02:00
Huang Xin 52f9634810 feat(backup): include global settings in backup zip (#4211)
* feat(backup): include global settings in backup zip

Backup zips previously held only book files and library.json. Issue
#4098 asks for app configuration to be backed up too.

A `settings.json` snapshot is now written at the zip root. Restore
deep-merges it onto the current device's settings, so fields the
snapshot omits keep their current values.

`sanitizeSettingsForBackup` strips, via a blacklist, fields that are
device-specific or sync/migration bookkeeping (filesystem paths,
replica/kosync device ids, sync cursors, lastOpenBooks, screen
brightness, schema versions). Account credentials (kosync/Readwise/
Hardcover tokens, AI gateway key, OPDS catalog logins) are stripped
unless the user opts in via a new "Include account credentials"
checkbox in the Backup & Restore dialog — the zip is unencrypted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backup): keep revived books visible after a cloud-synced restore

When the library is deleted (soft delete) and the deletion has synced
to the cloud, restoring an older backup un-deletes the books locally —
but the next sync's last-writer-wins merge re-applied the cloud's
deletion tombstone, so the restored books vanished again.

The deletion never bumps `updatedAt`, so a restored book and its cloud
tombstone share the same timestamp; `processOldBook` breaks the tie
toward the cloud.

`reviveRestoredBooks` now fixes up books that were soft-deleted locally
but present in the backup:

- Bumps `updatedAt` so the restore out-ranks the cloud tombstone. A
  single uniform offset is applied to every revived book, so their
  relative order — and the library's "Updated" sort — is preserved
  exactly; the newest maps to now, none land in the future.
- Clears `syncedAt` so the next push re-uploads them and corrects the
  cloud rows.
- Restores `downloadedAt` / `coverDownloadedAt` from the backup record
  (the local deletion had cleared them) so revived books are not shown
  as not-downloaded even though their files were re-extracted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:16:46 +02:00
Huang Xin 689537fd78 fix(ios): refresh appearance on system light/dark change, closes #4057 (#4210)
The window-level `overrideUserInterfaceStyle` applied by
`set_system_ui_visibility` pins the WKWebView's trait collection, so the
`prefers-color-scheme` media query never fires while the app stays
foregrounded and `get_system_color_scheme` returned the stale pinned
value. Detect appearance at the window-scene level instead — it sits
above the per-window override — and push changes to JS via
`window.onNativeColorSchemeChange`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:11:32 +02:00
Huang Xin 952304a956 fix(sync): push books row alongside in-reader progress auto-sync (#4209)
The library sync lane (useBooksSync) only runs while the library page is
mounted. While a reader stays open on one device, in-reader auto-sync
pushes `configs` but never re-pushes the `books` row, so other devices'
library pull-to-refresh keeps showing stale reading progress until the
source reader is closed.

useProgressSync.pushConfig now also forwards the in-memory library Book
through the books lane after pushing the config. useProgressAutoSave has
already merged config.progress into that Book via saveConfig, so the
books push carries the up-to-date progress.

Fixes #4198

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:44:17 +02:00
Huang Xin 0fba5b7054 feat(config): version book config schema (#4208) 2026-05-17 18:23:46 +02:00
Huang Xin 1d4b7eed87 fix(txt): merge scene-break sections into the preceding chapter (#4063) (#4207)
The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.

Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:20:55 +02:00
Huang Xin 83607d14ea fix(opds): send Basic auth preemptively for optional-auth servers (#4206)
OPDS servers that allow anonymous access (e.g. Calibre-Web) return 200
without a WWW-Authenticate challenge. `fetchWithAuth` only attached
credentials on a 401/403 retry, so a user who configured valid login
details kept seeing guest-only content (own shelves missing).

Send a Basic Authorization header on the first request whenever
credentials are available. Digest auth still falls through to the
challenge-driven retry since it can't be sent preemptively, and the
retry is skipped when it would just repeat the preemptive Basic header.

Fixes #4202

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:07:15 +02:00
Huang Xin c8fabd331c fix(reader): resolve KOReader sync conflict against non-KOReader servers (#4205)
The sync-conflict dialog had two issues with servers other than KOReader
(e.g. Kavita's KOReader-compatible sync endpoint):

- "This device" preview rendered a bare "undefined" because reflowable
  books built the string from `sectionLabel`, which is empty for spine
  items with no matching TOC entry. It now falls back to the page count.
- Choosing "use remote" closed the dialog but never moved the reader:
  `applyRemoteProgress` only knew how to navigate via CREngine XPointers,
  so non-XPointer progress strings were silently ignored. It now falls
  back to `view.goToFraction` using the reported percentage.

Also fixes the section-title indentation in the dialog (SectionTitle
bakes in `ps-4`, which misaligned the labels against their values).

Closes #4200

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:22:34 +02:00
loveheaven 9f0aa2f55d fix(tests): materialize zip.js blob before wrapping in File for case-mismatch fixture (#4203)
The case-mismatch EPUB fixture builds an archive with @zip.js/zip.js' BlobWriter and then wraps the resulting Blob into a File:

  const blob = await writer.close();
  new File([blob], 'case-mismatch.epub', ...);

Under vitest's happy-dom/jsdom, the File/Blob polyfill does not correctly pull bytes out of a nested Blob part produced by zip.js. The outer File reports a non-zero size, but the bytes BlobReader sees in DocumentLoader (libs/document.ts: 'new BlobReader(this.file)') are not a valid ZIP — getEntries() yields nothing, open() falls through with book = null, and the test crashes at:

  TypeError: Cannot read properties of null (reading 'sections')

Materialize the zip bytes into a plain ArrayBuffer first, then construct the File from that. ArrayBuffer parts go through the polyfill cleanly because they don't require recursive Blob unwrapping, so zip.js reads a real archive and the test passes:

  const arrayBuffer = await blob.arrayBuffer();
  new File([arrayBuffer], 'case-mismatch.epub', ...);

This brings the fixture in line with the rest of the test suite (paginator-expand, page-progress-epub, toc-cfi-mapping, ...) which already use ArrayBuffer-based File construction. No production code is affected: real browsers handle nested-Blob File construction correctly.
2026-05-17 16:31:19 +02:00
Huang Xin ba6e5899e5 feat(reader): RSVP CJK character mode and whole-word highlight (#4199)
* feat(reader): add RSVP CJK character mode and whole-word highlight, closes #4131

Add two CJK-only options to the RSVP overlay settings row:
- Character Mode: split CJK text per-character instead of by jieba/Intl
  word segmentation, restoring one-character-per-flash reading.
- Highlight Word: render a CJK word as a single centered, fully-colored
  span, fixing the focus-only highlight and even-length left-shift.

The focus point now skips trailing CJK punctuation so tokens like "是。"
highlight the character, not the punctuation. Both toggles appear only
for sections that contain CJK text.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n: extract RSVP CJK character mode and highlight word strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:30:11 +02:00
loveheaven 3620c61038 feat(reader): import annotations from Moon+ Reader (.mrexpt) (#4174)
* feat(reader): import annotations from Moon+ Reader (.mrexpt)

Add a new menu entry under the reader sidebar 'More' menu that lets users import highlights and notes exported from the Moon+ Reader Android app.

Implementation:

- utils/mrexpt.ts: parser for the .mrexpt plaintext format (entry id, NCX navPoint index b4, character offset b6, type marker, word and note).

- services/annotation/providers/mrexpt.ts: convert mrexpt entries to BookNote[] using bookDoc. Locate the chapter via b4 -> toc -> spine, then TreeWalker-search the section DOM for the highlighted word with English suffix tolerance (ing/ed/s/...). Falls back to a section-level CFI when the exact word can't be located. Re-imports are deduplicated by a stable id derived from entryId.

- BookMenu: add 'Import from Moon+ Reader' menu item dispatching the 'import-mrexpt' event.

- Annotator: handle 'import-mrexpt' — pick the file (Web File / Tauri path), parse, convert against the live bookDoc, merge into booknotes (latest updatedAt wins), persist via saveConfig, and apply to all live views so highlights appear immediately. User feedback via toasts (importing / imported N / N unmatched / nothing new).

* refactor(reader): simplify Moon+ Reader import notifications

Reworks the .mrexpt import UX so it shows exactly one toast per run
instead of up to two, and removes redundant intermediate notices.

- Drop the intermediate "Importing N annotations…" toast. The toast
  system shows one toast at a time, so it merely flashed and was
  replaced by the result toast.
- Drop the duplicate "Failed to read the selected file." toast in the
  read catch block; it falls through to the existing empty-content
  check which surfaces the same message.
- Collapse the three-way result toast (already imported / N unmatched /
  N imported) into one: "Imported {{count}} annotations" or
  "No new annotations to import".
- Fix a result-message bug: when every converted note was already
  imported and nothing was unmatched, the toast read "Imported 0
  annotations." It now reports "No new annotations to import".
- Pluralize the success message via i18n `count` (the previous `{{n}}`
  placeholder never pluralized, e.g. "Imported 1 annotations").
- Extract the dedupe/merge logic into a pure, unit-tested
  `mergeImportedBookNotes` helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate Moon+ Reader import strings

Run i18next extraction and translate the new .mrexpt import strings
across all 33 locales (340 keys). The import feature added in this PR
introduced translatable strings that had not yet been extracted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 05:37:26 +02:00
Huang Xin a20f68fc11 feat(readwise): allow overriding the Readwise sync base URL (#4196)
* feat(readwise): allow overriding the Readwise sync base URL

Add an advanced option to point Readwise sync/export at a custom,
Readwise-compatible endpoint instead of the hardcoded official API.
When the override is unset or blank, behavior is unchanged.

- ReadwiseClient resolves a custom `baseUrl` over `READWISE_API_BASE_URL`,
  trimming whitespace and trailing slashes.
- ReadwiseSettings gains an optional `baseUrl` field; it syncs as
  plaintext via the settings sync whitelist.
- ReadwiseForm exposes the URL under a collapsed "Advanced" disclosure
  on the connect screen, and surfaces a custom URL read-only once
  connected. Disconnect preserves the custom URL for easy reconnect.

Closes #4114

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* i18n(readwise): rename "Sync Base URL" label to "Custom URL"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:45:38 +02:00
Huang Xin ad1c2d6bb0 fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low-
magnitude wheel events — plus an inertial momentum tail — for a single
physical gesture, and even a light brush of the surface produces spurious
deltas. The previous 100ms trailing debounce collapsed bursts but did not
filter by magnitude, so isolated micro-touches and the momentum tail each
turned a page, cascading into continuous accidental page turns in
paginated mode.

Add a wheel gesture detector that accumulates normalized wheel travel and
only flips once it crosses a deliberate-intent threshold, then swallows the
rest of the stream (the momentum tail) until the wheel goes idle — so one
physical gesture flips exactly one page, mirroring native readers.

Closes #4117

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:30:53 +02:00
Huang Xin f2a2d96938 fix(koplugin): honor remote annotation deletions, closes #4119 (#4194)
Pull skipped notes carrying a deleted_at tombstone but never removed the
matching local annotation. A highlight deleted on Readest therefore lingered
in KOReader, and a later push (notably a full sync) re-uploaded it,
resurrecting the note on the server and making it reappear on every device.

Add removeDeletedAnnotations, invoked at the start of the pull callback, to
drop local annotations the server has tombstoned. Tombstones are matched by
stored id, by the hash-derived id for native KOReader highlights, or by
position/page xpointer.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:30:25 +02:00
Huang Xin f4483643f4 fix(tts): skip hidden footnotes in TTS, closes #4135 (#4193)
Footnotes/endnotes are hidden in the rendered page via `display: none`,
but TTS builds its blocks from its own document. For background
sections that document is raw XHTML loaded via `section.createDocument()`
without the page layout styles, so the footnotes were read aloud.

- `createRejectFilter` gains an `attributeTokens` option to match
  `aside[epub:type~="footnote|endnote|note|rearnote"]` (value-token
  match, like CSS `[attr~="x"]`), so footnotes are detectable on raw
  documents that lack the `epubtype-footnote` class.
- `TTSController` adds the footnote selectors to its reject filter.
- `getBlocks()` (foliate-js) skips the subtree of any block-level
  element the node filter rejects, ending the preceding block before
  it so footnote text doesn't leak in.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:23:54 +02:00
Huang Xin 8dfc0e945e fix(dictionary): normalize lookup query with trim + case fallback (#4192)
A double-click selection can carry trailing whitespace and most imported
dictionaries store headwords lowercased, so an exact match on the raw
selection often misses (e.g. `Hello` or `world ` fail to resolve
`hello`/`world`). Case-sensitive formats like mdict are hit hardest since
their reader compares the raw word.

Seed the lookup history with a trimmed word and try ordered query
variants (trimmed, lowercase, title-case, uppercase) per provider,
keeping the first hit. Closes #4176.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:52:48 +02:00
Huang Xin 2d30868d23 fix(fonts): hydrate custom fonts on library page, closes #4178 (#4191)
Custom fonts vanished from the Font panel after an app restart unless a
book was opened first. The custom-font store is hydrated only by the
reader's FoliateViewer (on book open) or by useReplicaPull (gated on a
signed-in user), so opening Settings straight from the library left the
store empty.

Add a useCustomFonts hook that loads persisted custom fonts on mount,
unconditional of auth or book state, and mount it on the library page.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:19:45 +02:00
Huang Xin d2ff47029c fix(opds): detect XML feeds with leading whitespace, closes #4181 (#4190)
OPDS responses were classified as XML vs JSON with `text.startsWith('<')`.
Some servers (e.g. the Hungarian MEK catalog) return a valid Atom feed
prefixed with newlines/whitespace before `<feed>`, no `<?xml?>`
declaration, and a wrong `text/html` Content-Type. The naive check missed
the `<`, so the XML body was handed to `JSON.parse`, failing with
"Unexpected token '<' ... is not valid JSON".

Add a shared `looksLikeXMLContent()` helper that trims leading whitespace
(also stripping a UTF-8 BOM) before the check, and use it in both
`loadOPDS` and `validateOPDSURL`. Detection is now based purely on the
body, so formally-valid feeds with a bad Content-Type work.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:03:28 +02:00
Huang Xin 40b7c2c15e refactor(reader): harden saveConfig updatedAt refresh (#4189)
saveConfig refreshed config.updatedAt by mutating the config object in
place. That only worked because every reader view shares one config
object reference, and it bypassed Zustand change-detection entirely.

Refresh updatedAt via an immutable setConfig store update instead, so it
no longer depends on callers sharing the same reference, notifies
subscribers, and never mutates the caller-provided object. Sync behavior
is unchanged.

Refs #4184

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:56:56 +02:00
Huang Xin 411d3ad687 fix: export annotations even without TOC, closes #4186 (#4188)
* i18n(ios): add more localized languages in plist

* fix: export annotations even without TOC, closes #4186
2026-05-16 19:22:18 +02:00
Huang Xin d0464c1031 i18n(ios): add more localized languages in plist (#4187) 2026-05-16 18:33:18 +02:00
leehuazhong 2acd08202b fix(a11y): use position absolute for skip-next-section link to prevent blank page (#4182)
* fix(a11y): use position absolute for skip-next-section link to prevent blank page

* fix(a11y): nest next-section skip link inside last content element

position:absolute alone does not fix the blank-page bug: a full-page
illustration wrapper commonly carries `column-break-after: always`, and
the skip link's static position after that break still renders in a
fresh, blank column. Nest the link inside the deepest last content
element so it shares the final content column, while remaining the last
node in document order for NVDA's virtual cursor. Also use left:auto so
it keeps its static position instead of pinning to the viewport edge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: leehuazhong <longsiyinyydds@gmail.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:13:04 +02:00
loveheaven 1a3d393e74 feat(reader): add "Clear Annotations" entry to the book menu (#4175)
Adds a "Clear Annotations" item to the book menu. Picking it opens a confirm dialog and, on confirm, soft-deletes every type='annotation' booknote on the active book by stamping deletedAt, removes overlays from live views, persists via saveConfig, and resets sidebar browse state. Bookmarks and excerpts are untouched. The dialog lives in Annotator (per-book, long-lived) and is wired up via a new 'clear-annotations' event so it survives the dropdown menu unmounting.
2026-05-16 04:12:46 +02:00
Huang Xin 787bbf2103 feat(reader): custom hardware-button page turning (#4177)
* feat(reader): add custom hardware-button page turning (#4139)

Lets users bind hardware remote keys (media keys, D-pad/arrow keys) to
previous/next page via a learn-mode capture UI in reader settings — an
accessibility feature for page-turner remotes.

- New global hardwarePageTurner system setting (enabled + key bindings).
- hardwareKeys.ts: key normalization, matching, and page-turn resolution.
- deviceStore: reference-counted media-key interception + learn mode.
- usePagination: flips pages from bound media keys (native bridge) and
  D-pad/keyboard keys (DOM keydown), scoped to the active book and
  suppressed while the toolbar is visible.
- Page Turner settings section on all platforms; web/desktop bind keys
  via DOM keydown only, native media-key interception stays mobile-only.
- Android: intercept media + learn-mode keys in dispatchKeyEvent.
- iOS: forward media keys via MPRemoteCommandCenter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): add and translate hardware page turner strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reader): refine hardware page turner (#4139)

- Handle book-iframe key events (iframe-keydown messages) so custom
  bindings work as soon as a book is open, not only after the settings
  panel has been shown.
- Add Previous/Next Section bindings alongside the page bindings.
- Rename the hardwareKeys util to keybinding.
- Wire the Page Turner section into the settings Reset action.
- Drop the focus ring on the capture buttons; BoxedList gains an
  optional description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(i18n): translate page turner section and key strings (#4139)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:57:33 +02:00
Huang Xin f5e729a174 fix(reader): revert smooth mouse-wheel scrolling in scroll mode, closes #4130 (#4172)
The smooth-wheel feature (#3974, closing #3966) intercepts mouse-wheel
events in scroll mode: it makes the wheel listener non-passive,
preventDefault()s the native scroll, and replays the delta through a
main-thread rAF animation against the renderer container.

That regressed normal mouse scrolling on Windows (#4130): fast wheel
bursts were discarded entirely, and the JS replay is structurally worse
than native scrolling -- a non-passive wheel listener forces every wheel
event (mouse and trackpad) off the compositor thread, and the
postMessage hop plus main-thread animation add latency and jank that
native compositor scrolling does not have.

High-resolution scrolling (e.g. Logitech MX Master, the mouse in #3966)
needs no special API: the OS/driver just delivers regular wheel events
with smaller, more frequent deltas, and the browser scrolls them
natively. #3966's own report ("smooth scrolling works with all
applications apart from yours") points at the interception, not a
missing capability. Restore native wheel scrolling in scroll mode.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:28:57 +02:00
Huang Xin 4cd5d56b49 fix(tts): retry Edge TTS preload up to 3 times on failure, closes #4147 (#4171)
Edge TTS websocket requests fail intermittently, and a single transient
failure during preload silently dropped the cached audio chunk, which
could stall playback. Add a #createAudioUrlWithRetry helper that retries
createAudioUrl up to 3 attempts with a short backoff, bailing early when
the abort signal fires. Both the immediate and background preload paths
in speak() now use it.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:25:27 +02:00
Huang Xin e0cb433550 fix(koplugin): harden cover-download subprocess against Adreno exit crash (#4169)
Browsing a folder in the Readest Library spawns a forked child per
cloud cover via syncbooks.downloadCover. On Boox / Adreno devices the
child crashed with a SIGSEGV (issue #4165): it terminated through the
libc exit() path, and __cxa_finalize ran the destructor of the GL
driver inherited from the parent, which segfaults on Adreno.

Terminate the child with ffi.C._exit(0) instead. _exit() skips libc
atexit handlers, so __cxa_finalize — and the Adreno destructor it runs
— never execute. The body is also wrapped in pcall so a network error
in http.request cannot unwind past that _exit call.

This eliminates the child-side crash in the reported tombstone. The
parent KOReader exiting is most likely a knock-on effect of the child
tearing down GPU state shared across the fork, but that link is not
provable from the log alone — so this intentionally does not
auto-close #4165 until confirmed on an affected device.

No unit test: the fork + network path isn't reproducible in the busted
harness, consistent with the other network methods in this file.
2026-05-15 10:52:09 +02:00
Huang Xin 7716f189c3 fix(layout): keep header/footer transparent and fixed in scrolled mode, closes #4157 (#4168)
Remove the redundant "Apply also in Scrolled Mode" options for bars and
margins so scrolled mode renders the header/footer consistently with
paginated mode: transparent, fixed in position, and not obscuring content.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:30:43 +02:00
Huang Xin ab2def32dd fix(koplugin): stop sync from wiping cloud book fields + Library polish (#4166)
* fix(koplugin): pull before push so sync doesn't wipe cloud book fields, closes #4138

The Library sync pushed a touched book row before pulling, so a row
still missing the cloud's uploaded_at / metadata / group_id (e.g. one
created by lightScan, not yet merged from a cloud pull) was sent with
those fields nil. The server's transformBookToDB explicit-nulls
uploaded_at and metadata for any field absent from the wire payload,
wiping the cloud copy — after which every device that pulled lost the
book's upload state.

syncBooks("both") now pulls first, then pushes, and takes a before_push
callback. syncBooksLibrary passes touchOpenBook through it so the
open book's updated_at bump lands after the pull has refreshed the
local row, letting the push carry the preserved cloud fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(koplugin): hide Library books with neither an uploaded nor a local file

The Library showed any row with cloud_present = 1, but a bare cloud
*record* whose file was never uploaded (uploaded_at NULL) has no cover
and can't be opened — showing it is meaningless. Tighten the visibility
predicate to (uploaded_at IS NOT NULL OR local_present = 1) across
listBooks, getGroups, listBookshelfGroups and listBooksInGroup.

This mirrors Readest, which only adds a synced book to the library when
uploadedAt is set and keeps locally-imported books that carry a
downloadedAt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(koplugin): close the Library widget when opening a book

Opening a book from the Library called ReaderUI:showReader without
closing the Library Menu, so it stayed in the UIManager widget stack
with M._menu still set. A later background M.refresh() — a cloud-sync
or cover-download completion — then repainted that ghost Library over
the reader, making it flash on screen for a few seconds.

Add M.close(); route the title-bar X, M.reopen() and both handleTap
book-open paths through it. A wrapped onCloseWidget clears M._menu on
every close path, so M.refresh() no-ops once the Menu is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:05:49 +02:00
Bailey Jennings cea25ef465 fix(koplugin): omit empty note field when syncing annotations (#4161)
When syncing highlights between Readest and KOReader, the `note` field
was forced to an empty string (`""`) for annotations and bookmarks
without notes. KOReader's native annotations omit the field entirely
when no note exists, so the empty string caused KOReader to treat
every synced highlight as having a (blank) note. Apply the same
omission in both push and pull directions.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 20:37:30 +02:00
Huang Xin 16ffc17507 fix(eink): fixed sync toggle styles in eink mode, closes #4155 (#4163) 2026-05-14 19:57:23 +02:00
Huang Xin 708e06a46e fix(opds): show summary as book description, closes #4156 (#4162)
The TypeScript types in `src/types/opds.ts` declared fresh
`Symbol('content')` / `Symbol('summary')` instances. foliate-js's
`opds.js` declared its own distinct ones, and since Symbols are unique
per call, `metadata[SYMBOL.CONTENT]` always returned undefined — even
though the parser had written the value under a same-named Symbol.

This broke silently in 0.11.1 after foliate-js #14 stopped also setting
a plain `content: string` fallback. For OPDS 1.x feeds (e.g. CWA) the
book description lives in `<entry><summary>`, which foliate-js exposes
only via `[SYMBOL.CONTENT]` — so the description vanished.

Re-export the SYMBOL from foliate-js so consumers read the same Symbol
identities the parser writes.
2026-05-14 19:23:32 +02:00
Huang Xin 244b3fd994 fix(dev): rewrite HMR WebSocket URL in Tauri mobile dev, closes #4150 (#4160)
In Tauri mobile dev the page origin doesn't match the dev server, so
Next.js's `getSocketUrl` builds an unreachable HMR URL (`wss://localhost`
on iOS, `ws://tauri.localhost` on Android), the HMR client never connects,
and the page stays blank.

Inject a tiny script in `<head>` (dev + Tauri only) that subclasses
`window.WebSocket` and rewrites the broken URL to the actual dev server.
`TAURI_DEV_HOST` is forwarded from the build env so `pnpm tauri {ios,android}
dev --host <ip>` also routes HMR through the LAN address.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:06:22 +02:00
Jon Volkmar f6b6281160 fix(kosync): add namespace to koreader plugin modules to avoid collision (#4153) 2026-05-14 08:56:36 +08:00
Huang Xin 54aa20d4f8 fix(footnote): don't treat in-book numeric chapter/verse links as footnotes (#4152)
closes #4140

The bare-numeric-text heuristic added in #3894 to detect non-superscript
footnotes (`/^.{0,2}\d+$/` over `anchor.textContent`) was too permissive:
in-book TOCs that list chapter/verse links such as `<a>1</a>, <a>2</a>, ...`
all match the regex, so clicking them sets `check=true` and the footnote
handler renders the destination as a popup instead of letting the link
navigate. The OSB v2 verse-index and OSB v4 chapter-index from the bug
report both hit this.

Reject the `check` heuristic when the clicked link sits inside a numeric
link list (2+ sibling links with the same short-numeric pattern within
three ancestor levels). A real body paragraph with a couple of footnote
markers still passes; a flat TOC of numeric links does not.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:33:26 +02:00
Huang Xin 041af6859f fix(sync): publish custom css settings after applying css, closes #4146 (#4151) 2026-05-13 18:25:57 +02:00
Huang Xin 7d3065d9ae feat(android): also upgrade webview from beta, dev and canary channels when the stable channel isn't updatable (#4149) 2026-05-13 17:40:26 +02:00
Roy Zhu fed8ab7b67 fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
When TTS playback crosses a section boundary, the page would stay on
the last page of the previous chapter while audio continued reading
the next chapter — leaving the user stuck behind the "back-to-TTS"
button.

Two compounding issues since the paginator adjacent-section preloading
landed:

1. `handleSectionChange` called `view.renderer.goTo(resolved)` without
   awaiting. `TTSController.#initTTSForSection` does
   `await this.onSectionChange?.(sectionIndex)` precisely so the view
   can finish navigating before audio of the new section starts, but
   the missing await defeated that contract.

2. `handleHighlightMark` returned silently on a cross-section
   mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the
   renderer.goTo above completed only partially — which can happen on
   the new paginator when the target section is already loaded as an
   adjacent view and the post-goTo state appears reused without a
   visible page flip — there was no second chance to drag the view to
   the TTS cfi.

Fix:

- Await `view.renderer.goTo` in `handleSectionChange`.
- In `handleHighlightMark`, run the cross-section branch *before* the
  `followingTTSLocationRef` check and call `view.goTo(cfi)` directly,
  stamping `sectionChangingTimestampRef` so the back-to-TTS button
  stays suppressed while progress.location catches up. Skip only when
  the user is actively selecting text.

Adds unit tests covering both the cross-section navigation path and
the in-section scrollToAnchor path.
2026-05-13 16:44:10 +02:00
JustADeer 9a05935caf feat(reader): improve Japanese selection UX by disabling furigana selection (#4137)
* feat: add default ruby rt styles with user-select: none

* fix: prevent furigana text from being copied via ruby transformer

* fix: register ruby transformer in FoliateViewer pipeline; use span wrapper for reliable ::before rendering

* refactor(reader): simplify furigana copy exclusion

Drop the ruby transformer and the .rt-text::before pseudo-element
wrapping. Instead, pass ['rt'] to getTextFromRange unconditionally so
furigana is excluded from annotator/translation/copy text extraction,
and let `rt { user-select: none }` handle the native selection cursor.

Avoids DOM rewriting and HTML-entity round-tripping in the data-text
attribute, and keeps <rt> text in the DOM for TTS, in-page find, and
screen readers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:39:57 +02:00
Huang Xin 11469d1e95 chore(deps): bump vulnerable Rust dependencies (#4144)
Update Cargo.lock to resolve Dependabot advisories by bumping:
- openssl to 0.10.79
- openssl-sys to 0.9.115
- rustls-webpki to 0.103.13
2026-05-13 11:33:34 +02:00
Huang Xin e8df651d5a chore(deps): bump Next.js to version 16.2.6 (#4143) 2026-05-13 10:57:06 +02:00
Huang Xin fc71ca9857 feat(android): upgrade in-process WebView on devices stuck on old system WebView (#4142)
Add tauri-plugin-webview-upgrade as a git submodule under
apps/readest-app/src-tauri/plugins/. On Android devices whose system
WebView is locked to an old Chromium build (Huawei phones, Moaan / Onyx
/ Kobo e-ink readers, AOSP forks without Play Store, etc.), the reader
bundle renders as a blank screen. The plugin bootstraps before
Application.onCreate via androidx.startup and redirects the in-process
WebView loader to a recent com.google.android.webview when the user has
one sideloaded — opening the only window in which WebViewUpgrade can
swap the provider, before Tauri/Wry creates any WebView.

Thresholds (minUpgradeMajor / minSupportedMajor) come from
plugins.webview-upgrade in tauri.conf.json and are baked into Kotlin
constants at Gradle build time. Below the supported threshold with no
upgrade option, the plugin shows a localized AlertDialog (15 languages,
English fallback) prompting the user to install Android System WebView.

Plugin source: https://github.com/readest/tauri-plugin-webview-upgrade

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:01:13 +02:00
Bailey Jennings 058d58b4f2 fix(kosync): populate chapter field on synced annotations (#4134)
Annotations and bookmarks inserted into KOReader via the Readest sync
plugin were missing the chapter field, which native KOReader highlights
stamp at creation time. Downstream tools that group highlights by
chapter (e.g. obsidian-koreader-highlights, KOReader's own Markdown
exporter) treated these as orphans.

Resolve the chapter title from the xpointer using the same TOC call
that ReaderHighlight uses natively, and include it on both annotation
and bookmark item tables.

Closes #4133
2026-05-12 08:57:45 +08:00
Huang Xin 615dc82c17 fix(android): fixed .mdx/.mdd files not shown in file chooser on Android, closes #4124 (#4125) 2026-05-11 08:50:35 +02:00
753 changed files with 75790 additions and 7477 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"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"
}
+22 -1
View File
@@ -13,8 +13,30 @@ target
# 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
@@ -28,4 +50,3 @@ Thumbs.db
# Logs
*.log
npm-debug.log*
+5 -3
View File
@@ -19,6 +19,8 @@ on:
schedule:
- cron: '38 20 * * 4'
permissions: read-all
jobs:
analyze:
name: Analyze (${{ matrix.language }})
@@ -59,7 +61,7 @@ jobs:
# 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@v6
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`
@@ -69,7 +71,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -98,6 +100,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
with:
category: '/language:${{matrix.language}}'
+191
View File
@@ -0,0 +1,191 @@
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"
+84 -43
View File
@@ -5,8 +5,7 @@ on:
pull_request:
branches: [main]
permissions:
contents: write
pull-requests: write
contents: read
jobs:
rust_lint:
runs-on: ubuntu-latest
@@ -15,19 +14,19 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup sccache
uses: mozilla-actions/sccache-action@v0.0.10
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: Install minimal stable with clippy and rustfmt
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
toolchain: stable
override: true
components: rustfmt, clippy
- name: Cache apt packages
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-rust-lint-${{ runner.os }}
@@ -45,32 +44,35 @@ jobs:
build_web_app:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
# 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: nextjs-web-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
key: turbo-build-web-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-web-${{ github.sha }}-
nextjs-web-
turbo-build-web-${{ runner.os }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-vendors
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
@@ -89,35 +91,61 @@ jobs:
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@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
with:
path: apps/readest-app/.next/cache
key: nextjs-test-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-test-${{ github.sha }}-
nextjs-test-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-vendors
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: |
@@ -132,9 +160,21 @@ jobs:
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: npx playwright install --with-deps chromium
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
@@ -150,48 +190,49 @@ jobs:
SCCACHE_GHA_ENABLED: 'true'
RUSTC_WRAPPER: sccache
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- name: setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: cache Next.js build
uses: actions/cache@v5
# 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/cache
key: nextjs-tauri-${{ github.sha }}-${{ hashFiles('pnpm-lock.yaml') }}
path: apps/readest-app/.next/dev/cache
key: turbo-dev-tauri-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
nextjs-tauri-${{ github.sha }}-
nextjs-tauri-
turbo-dev-tauri-${{ runner.os }}-
- name: install Dependencies
working-directory: apps/readest-app
run: |
pnpm install && pnpm setup-vendors
pnpm install --frozen-lockfile --prefer-offline && pnpm setup-vendors
- name: setup sccache
uses: mozilla-actions/sccache-action@v0.0.10
uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
- name: install Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
uses: actions-rust-lang/setup-rust-toolchain@46268bd060767258de96ed93c1251119784f2ab6 # v1
with:
toolchain: stable
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: tauri-cargo
cache-all-crates: 'true'
- name: Cache apt packages
uses: actions/cache@v5
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: /var/cache/apt/archives
key: apt-tauri-${{ runner.os }}
+46 -19
View File
@@ -5,10 +5,12 @@ on:
release:
types: [published]
permissions: read-all
jobs:
get-release:
permissions:
contents: write
contents: read
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.get-release.outputs.release_id }}
@@ -17,14 +19,14 @@ jobs:
release_version: ${{ steps.get-release-notes.outputs.release_version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: setup node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- 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@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { data } = await github.rest.repos.getLatestRelease({
@@ -35,7 +37,7 @@ jobs:
core.setOutput('release_tag', data.tag_name);
- name: get release notes
id: get-release-notes
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const fs = require('fs');
@@ -57,7 +59,7 @@ jobs:
steps:
- name: update release
id: update-release
uses: actions/github-script@v9
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
release_id: ${{ needs.get-release.outputs.release_id }}
release_tag: ${{ needs.get-release.outputs.release_tag }}
@@ -87,7 +89,7 @@ jobs:
contents: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: create KOReader plugin zip
env:
@@ -154,47 +156,47 @@ jobs:
runs-on: ${{ matrix.config.os }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: initialize git submodules
run: git submodule update --init --recursive
- name: setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6
- name: setup node
uses: actions/setup-node@v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 24
cache: pnpm
- name: setup Java (for Android build only)
if: matrix.config.release == 'android'
uses: actions/setup-java@v5
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@v4
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
run: pnpm install --frozen-lockfile --prefer-offline
- name: copy pdfjs-dist and simplecc-dist to public directory
run: pnpm --filter @readest/readest-app setup-vendors
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
targets: ${{ matrix.config.rust_target }}
- uses: Swatinem/rust-cache@v2
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
key: ${{ matrix.config.os }}-${{ matrix.config.release }}-${{ matrix.config.arch }}-cargo
@@ -271,7 +273,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd apps/readest-app/
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
# 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"
@@ -295,7 +308,7 @@ jobs:
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@v0
- uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 # v0
if: matrix.config.release != 'android'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -311,6 +324,10 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=8192'
with:
projectPath: apps/readest-app
# On Linux, build with the Rust `cargo tauri` CLI installed in the
# step above so the new (truly-portable AppImage) bundler is used.
# Without this, tauri-action falls back to the npm @tauri-apps/cli.
tauriScript: ${{ matrix.config.release == 'linux' && 'cargo tauri' || '' }}
releaseId: ${{ needs.get-release.outputs.release_id }}
releaseBody: ${{ needs.get-release.outputs.release_note }}
args: ${{ matrix.config.args || '' }}
@@ -373,7 +390,18 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
# 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 }}
@@ -404,7 +432,6 @@ jobs:
needs: [get-release, build-tauri]
permissions:
contents: read
actions: write
uses: ./.github/workflows/upload-to-r2.yml
with:
tag: ${{ needs.get-release.outputs.release_tag }}
-20
View File
@@ -1,20 +0,0 @@
name: Retry workflow
on:
workflow_dispatch:
inputs:
run_id:
required: true
jobs:
rerun:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: rerun ${{ inputs.run_id }}
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: |
gh run watch ${{ inputs.run_id }} > /dev/null 2>&1
gh run rerun ${{ inputs.run_id }} --failed
+78
View File
@@ -0,0 +1,78 @@
# 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
+6 -13
View File
@@ -14,6 +14,9 @@ on:
required: true
type: string
permissions:
contents: read
jobs:
upload-to-r2:
runs-on: ubuntu-latest
@@ -36,7 +39,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install rclone
run: curl https://rclone.org/install.sh | sudo bash
run: |
sudo apt-get update
sudo apt-get install -y rclone
- name: Configure rclone
run: |
@@ -67,15 +72,3 @@ jobs:
- name: Upload successful
if: success()
run: echo "Upload completed successfully"
retry-on-failure:
if: failure() && fromJSON(github.run_attempt) < 3
needs: [upload-to-r2]
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: gh workflow run retry-workflow.yml -F run_id=${{ github.run_id }}
+6 -6
View File
@@ -4,20 +4,20 @@ on:
branches:
- main
permissions:
contents: write
deployments: write
pull-requests: write
contents: read
jobs:
build_and_deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
submodules: 'true'
- uses: amondnet/vercel-action@v42
- uses: amondnet/vercel-action@de09aeac2ace6599ec9b11ef87558759a496bac4 # v42
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
github-comment: false
vercel-args: '--prod'
vercel-org-id: ${{ secrets.ORG_ID}}
vercel-project-id: ${{ secrets.PROJECT_ID}}
+3
View File
@@ -22,3 +22,6 @@
[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
+1
View File
@@ -1,2 +1,3 @@
pnpm -C apps/readest-app format:check
pnpm -C apps/readest-app lint
pnpm -C apps/readest-app test
-38
View File
@@ -1,38 +0,0 @@
# Dependencies
node_modules
pnpm-lock.yaml
# Build Artifacts (Web & Rust)
.next
.open-next
.build
.tauri
out
build
dist
target
fastlane
.wrangler
# Autogenerated Tauri files
gen
**/autogenerated
**/schemas
# Submodules (External Repos)
packages
# Claude Code Skills & Config
apps/readest-app/.claude
# Vendored Assets (Generated/External Code)
apps/readest-app/public/*.js
apps/readest-app/public/vendor
apps/readest-app/src-tauri/plugins/tauri-plugin-turso
# Environment & Editor
.env
.env.*
.vscode
.idea
*.log
-10
View File
@@ -1,10 +0,0 @@
{
"trailingComma": "all",
"printWidth": 100,
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"jsxSingleQuote": true,
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}
+1 -1
View File
@@ -2,7 +2,7 @@
"recommendations": [
"ms-vscode.vscode-typescript-next",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"biomejs.biome",
"rust-lang.rust-analyzer"
]
}
+7 -6
View File
@@ -13,19 +13,20 @@
"javascript.validate.enable": false,
"javascript.format.enable": false,
"typescript.format.enable": false,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
"editor.defaultFormatter": "biomejs.biome"
},
"prettier.requireConfig": true,
"prettier.documentSelectors": ["**/*.{js,jsx,ts,tsx,css,json,md,html,yml}"]
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+1 -1
View File
@@ -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)
- Prettier - Code formatter (esbenp.prettier-vscode)
- Biome - Code formatter and linter (biomejs.biome)
- rust-analyzer (rust-lang.rust-analyzer) (for Tauri development only)
### When you're done
Generated
+819 -485
View File
File diff suppressed because it is too large Load Diff
+27 -8
View File
@@ -1,25 +1,37 @@
FROM docker.io/node:22-slim AS base
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@10.29.3 --activate
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/
FROM base AS dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
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 dependencies AS development-stage
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 base AS build
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
@@ -35,6 +47,13 @@ COPY . .
WORKDIR /app/apps/readest-app
RUN pnpm build-web
FROM build as production-stage
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
View File
@@ -0,0 +1 @@
../.claude/memory
+1
View File
@@ -0,0 +1 @@
../.claude/plans
+1
View File
@@ -0,0 +1 @@
../.claude/rules
+37
View File
@@ -8,6 +8,16 @@
- [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
@@ -16,16 +26,41 @@
- `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
@@ -34,6 +69,8 @@
- 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
@@ -0,0 +1,31 @@
---
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.
@@ -0,0 +1,48 @@
---
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.
@@ -114,6 +114,13 @@
**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
@@ -0,0 +1,27 @@
---
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.)
@@ -0,0 +1,21 @@
---
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]].
@@ -0,0 +1,53 @@
---
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]].
@@ -0,0 +1,19 @@
---
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.
@@ -0,0 +1,25 @@
---
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]].
@@ -0,0 +1,36 @@
---
name: issue-4112-scroll-anchoring
description: "Root cause for readest#4112 scrolled-mode backward-nav bugs — scroll-anchoring suppressed at scrollTop 0 when prepending a section"
metadata:
node_type: memory
type: project
originSessionId: e0b11058-53ee-4554-a518-134f788823ee
---
**RESOLVED** — merged in readest/readest#4349 (foliate-js fix + submodule bump + browser tests). Kept as reusable paginator scroll-mode knowledge.
readest/readest#4112 — two scrolled-mode bugs, ONE root cause.
**Root cause:** Browser scroll-anchoring (`overflow-anchor: auto`, paginator.js container) is **suppressed when scrollTop === 0**. The multiview paginator preloads the *previous* section by inserting its View element **above** the current one (`#loadAdjacentSection`, sorted insertion in `#createView`). When that prepend happens while `scrollTop === 0`, the inserted section pushes the current content down with no scroll compensation, so the viewport ends up showing the previous section.
**Bug 1 (TOC backward jump → lands on n-1):** Reproduce by navigating *one section back* (target N-1 is an already-loaded adjacent view → `#goTo` "view already loaded" branch, NOT `#display`). `scrollToAnchor(0)` lands at scrollTop 0 with target as topmost view; ~250ms later the **debounced** backward-preload (paginator.js ~line 977) inserts N-2... wait, inserts the section before the target, at scrollTop 0 → suppression → viewport drifts to target-1. Intermittent (~1/32/3) because it races `#fillVisibleArea`'s reanchor. `primaryIndex` stays = target but the *visible* top section = target-1.
**Bug 2 (can't scroll up / jumps to beginning of prev section):** same suppression — prev section inserted above at scrollTop≈0 shifts viewport to the *beginning* of prev instead of staying put. Backward-preload is debounced-only (forward preload is eager/immediate) → asymmetry adds lag.
**FIX (landed on foliate-js branch `fix/scrolled-prev-prepend-anchor`, 2 changes in `#loadAdjacentSection` + `#goTo`):**
1. Manual scroll compensation at the single prepend choke point `#loadAdjacentSection`: when prepending in scrolled mode (`index < sortedViews[0]`), after `await view.load()`, set `#renderedStart` to `startBefore + addedSize`. `containerPosition += (#vertical ? -1 : 1) * correction`; no-op (correction≈0) when the browser already anchored at scrollTop>0. Fixes drift (Bug 1) + scroll-up-shows-beginning (Bug 2b).
2. The already-loaded `#goTo` branch only preloaded prev for *short* sections (`contentPages < columnCount`); changed to `needsPrev || this.scrolled` (+ `#isSameDirection` guard), mirroring `#display`. Fixes can't-scroll-up (Bug 2a) — the debounced backward-preload bails while `#stabilizing` after nav, so nav must preload prev itself.
**UX follow-ups (same branch, same file):**
3. **No blank flash on adjacent nav**: the already-loaded `#goTo` branch faded the container `opacity 0→1`; in continuous scrolled mode that flashed (worse after change 2 put the prev-load inside the blank window). Now `blank = !this.scrolled || this.noContinuousScroll` — continuous scrolled scrolls straight to the (already-rendered) target. `loadPrev` helper: paginated loads prev BEFORE the scroll (fill leading columns), scrolled loads it AFTER (instant transition; compensation keeps position).
4. **Eager backward preload**: removed the debounced, one-viewport-gated backward preload; added an eager one in the immediate scroll listener mirroring forward (`pagesBehind < minPages`, scrolled-gated). Fixes "scroll up dead-ends at top until you nudge down". Safe now because change 1 compensation handles position stability (the old "debounced to avoid cascade" reason is obsolete).
**Verified live + tests.** Scrolled regression tests live in `paginator-scrolled.browser.test.ts` (split out of the old `paginator-multiview.browser.test.ts`, which was renamed to `paginator-paginated.browser.test.ts` for the default/paginated + CFI tests). The 4 #4112 tests: drift / prev-preload-after-nav / no-blank / eager-backward-within-a-few-viewports (+ the moved 'columnCount=1 in scrolled mode' and the #3987 toggle-off test). `pnpm test` (4921) + `pnpm lint` + `pnpm format:check` + 57 paginator browser tests green (4 files: scrolled, paginated, expand, stabilization).
**GOTCHA for live verification:** programmatic `el.scrollTop = N` does NOT fire 'scroll' events in the claude-in-chrome context (real wheel/touch does). To test scroll-driven preloading via the JS console, set scrollTop AND `el.dispatchEvent(new Event('scroll'))`. Also: the Next.js 16 dev server bundles foliate-js (transpilePackages); editing paginator.js hot-reloads, but verify the served chunk has your edit (fetch `_next/static/chunks/packages_foliate-js_paginator_*.js` and grep) — recompile can lag.
readest-app test change is uncommitted on `dev`; foliate-js fix on branch `fix/scrolled-prev-prepend-anchor` (uncommitted) → needs a PR to readest/foliate-js then a submodule bump.
**Verification harness:** localhost:3000/reader/<id> book "凡人修仙传" (2470 sections). Expose `__pg`/`__fv`, tag view elements with section index via `iframe.contentDocument` identity, measure `visibleTopSec()` vs target after settle. jsdom CANNOT reproduce (no real layout); use Chrome.
Key file: `packages/foliate-js/paginator.js` (submodule, fork readest/foliate-js).
@@ -0,0 +1,18 @@
---
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]].
@@ -0,0 +1,20 @@
---
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`).
@@ -0,0 +1,21 @@
---
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.
@@ -0,0 +1,47 @@
---
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]].
@@ -0,0 +1,71 @@
---
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]].
@@ -0,0 +1,52 @@
---
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]].
@@ -0,0 +1,21 @@
---
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]].
@@ -23,6 +23,12 @@
- **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
@@ -0,0 +1,18 @@
---
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]].
@@ -0,0 +1,79 @@
---
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.
@@ -0,0 +1,38 @@
---
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]].
@@ -0,0 +1,36 @@
---
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]].
@@ -0,0 +1,29 @@
---
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}`.
@@ -0,0 +1,17 @@
---
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]].
@@ -0,0 +1,37 @@
---
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]].
@@ -0,0 +1,18 @@
---
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]].
+9
View File
@@ -11,6 +11,9 @@
.test-sandbox-node/
.vitest-attachments/
# benchmarks — personal local perf history; share via PR/issue copy-paste
bench/results.jsonl
# next.js
/.next/
/out/
@@ -71,3 +74,9 @@ src-tauri/gen
.claude/settings.local.json
.claude/skills
# Playwright web e2e
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+22 -21
View File
@@ -23,8 +23,8 @@ pnpm test:tauri # Run Tauri integration tests
# Linting & Formatting
pnpm lint # Biome (linter) + tsgo (type check)
pnpm format # Prettier (runs from monorepo root)
pnpm format:check # Check formatting without writing
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)
@@ -65,9 +65,28 @@ 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 `.claude/rules/`: test-first, typescript, verification.
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
@@ -91,21 +110,3 @@ Every new UI widget must look right under `[data-eink='true']`. E-ink screens ha
- **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.
Available gstack skills:
- `/plan-ceo-review` — CEO/founder-mode plan review
- `/plan-eng-review` — Eng manager-mode plan review
- `/plan-design-review` — Designer's eye review of a live site
- `/design-consultation` — Design system consultation
- `/review` — Pre-landing PR review
- `/ship` — Ship workflow (merge, test, review, bump, PR)
- `/browse` — Fast headless browser for QA and site interaction
- `/qa` — QA test and fix bugs
- `/qa-only` — QA report only (no fixes)
- `/qa-design-review` — Designer's eye QA with fixes
- `/setup-browser-cookies` — Import cookies for authenticated testing
- `/retro` — Weekly engineering retrospective
- `/document-release` — Post-ship documentation update
If gstack skills aren't working, run `cd .claude/skills/gstack && ./setup` to build the binary and register skills.
+57
View File
@@ -0,0 +1,57 @@
# Benchmarks
Manual performance benchmarks for the readest-app. **Not run in CI** — CI runners
have shared-tenant variance that makes performance regression detection unreliable
(numbers swing 2-10× between runs). These exist so anyone considering an
architecture change can produce reproducible before/after numbers on their own
hardware.
## Run
```bash
pnpm bench # run every bench/*.bench.ts
pnpm bench vector-retrieval # run a single benchmark by name
pnpm bench --no-record # run but don't append to bench/results.jsonl
pnpm bench --list # list available benchmarks
```
Refuses to run when `$CI` is set. Append `--force` to override (don't unless
you've explicitly opted into running benches in CI for a one-off investigation).
## Output
Each run prints a header with machine info (platform, CPU, Node version, key
package versions) followed by per-benchmark results. By default, results are
also appended to `bench/results.jsonl` (gitignored) — your personal local
history. To share numbers, paste the table from the terminal into a PR or issue.
## When to add a new benchmark
When you're proposing an architecture change and need numbers to defend it. The
benchmark should:
1. Live at `bench/<name>.bench.ts`.
2. Export `default { name, description, run(ctx) }` matching the type in `lib.ts`.
3. Print human-readable results to stdout and return structured results to the
harness so they get logged to `results.jsonl`.
4. Be self-contained — no fixtures outside `bench/`, no I/O outside the bench
directory and an in-memory database.
5. Run in under ~30 seconds at default sample sizes. If you need long-running
scenarios, gate them behind a CLI flag.
## When *not* to add a benchmark
- "Just in case" — performance infrastructure has carrying cost. Wait until
you have a real architecture question that numbers will answer.
- To benchmark upstream libraries' performance (e.g., raw Turso function
throughput). That belongs in the upstream project's bench suite.
- To gate CI on performance thresholds. CI variance makes that flaky; use
production telemetry (`reedy_metrics` table) for regression detection
against real workloads.
## Existing benchmarks
- **`vector-retrieval`** — proves Turso's brute-force vector search is
SIMD-accelerated and fast enough for Reedy MVP corpus sizes (sub-millisecond
at 400 chunks × 768 dim, ~14 ms at 10K chunks × 768 dim). Established the
decision in plan §M1.5 to skip ANN indexes (which Turso doesn't ship anyway).
+115
View File
@@ -0,0 +1,115 @@
import { readdirSync, appendFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { type Bench, type BenchResult, formatHeader, formatResults, machineInfo } from './lib.ts';
const BENCH_DIR = dirname(fileURLToPath(import.meta.url));
const RESULTS_FILE = join(BENCH_DIR, 'results.jsonl');
interface Args {
filter: string | null;
record: boolean;
list: boolean;
force: boolean;
}
function parseArgs(argv: string[]): Args {
const args: Args = { filter: null, record: true, list: false, force: false };
for (const arg of argv) {
if (arg === '--no-record') args.record = false;
else if (arg === '--list') args.list = true;
else if (arg === '--force') args.force = true;
else if (!arg.startsWith('--')) args.filter = arg;
else {
console.error(`Unknown flag: ${arg}`);
process.exit(2);
}
}
return args;
}
async function loadBenches(): Promise<Bench[]> {
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith('.bench.ts'));
const benches: Bench[] = [];
for (const file of files) {
const mod = await import(resolve(BENCH_DIR, file));
const bench = mod.default as Bench;
if (!bench || typeof bench.run !== 'function') {
console.error(`Skipping ${file}: no default export with .run()`);
continue;
}
benches.push(bench);
}
return benches.sort((a, b) => a.name.localeCompare(b.name));
}
function gitCommit(): string {
try {
return execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
} catch {
return 'unknown';
}
}
function recordRun(bench: Bench, results: BenchResult[], commit: string): void {
if (!existsSync(BENCH_DIR)) mkdirSync(BENCH_DIR, { recursive: true });
const entry = {
ts: new Date().toISOString(),
commit,
bench: bench.name,
platform: process.platform,
arch: process.arch,
node: process.version,
results,
};
appendFileSync(RESULTS_FILE, JSON.stringify(entry) + '\n');
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (process.env['CI'] && !args.force) {
console.error('Refusing to run benchmarks in CI (CI=' + process.env['CI'] + ').');
console.error('Pass --force if you really mean it. See bench/README.md for why.');
process.exit(1);
}
const benches = await loadBenches();
if (args.list) {
console.log('Available benchmarks:');
for (const b of benches) console.log(` ${b.name.padEnd(20)} ${b.description}`);
return;
}
const selected = args.filter ? benches.filter((b) => b.name === args.filter) : benches;
if (selected.length === 0) {
console.error(`No benchmark named "${args.filter}". Try --list.`);
process.exit(2);
}
const info = machineInfo(['@tursodatabase/database', '@readest/turso-database-wasm']);
console.log(formatHeader(info));
const commit = gitCommit();
console.log(` git commit : ${commit}`);
console.log(` recording : ${args.record ? RESULTS_FILE : 'disabled (--no-record)'}`);
console.log('═'.repeat(70));
for (const bench of selected) {
process.stdout.write(`Running ${bench.name}... `);
const t0 = performance.now();
const results = await bench.run({ verbose: false });
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
console.log(`done in ${elapsed}s`);
console.log(formatResults(bench.name, results));
if (args.record) recordRun(bench, results, commit);
}
console.log('');
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+119
View File
@@ -0,0 +1,119 @@
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`;
}
@@ -0,0 +1,87 @@
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;
+581
View File
@@ -0,0 +1,581 @@
# Readest Architecture
This document gives a system-level view of Readest: how the pieces fit together,
which side of the wire each piece runs on, and what each module is responsible
for. It complements [`code-layout.md`](./code-layout.md), which focuses on the
directory layout. Read this one first if you want to understand the system; read
that one when you need to find a specific file.
The diagrams use [Mermaid](https://mermaid.js.org/) and render natively on
GitHub.
## 1. High-level picture
Readest is a single TypeScript/React codebase (`apps/readest-app`) compiled into
multiple targets:
- a **desktop app** (Windows / macOS / Linux) via Tauri v2
- a **mobile app** (Android / iOS) via Tauri v2 mobile
- a **web app** running on Next.js / Cloudflare Workers (OpenNext) at
[web.readest.com](https://web.readest.com)
- two **side surfaces**: a "Send to Readest" browser extension
(`apps/readest-app/extension/send-to-readest`) and a Windows thumbnail
shell extension (`apps/readest-app/extensions/windows-thumbnail`)
The same React UI runs in all targets. What differs is the **host shell** under
the UI and the **set of services** that the UI binds to at runtime — see
section 4.
```mermaid
flowchart LR
subgraph Clients
Desktop["Desktop app<br/>(Tauri shell + React UI)"]
Mobile["Mobile app<br/>(Tauri Android/iOS + React UI)"]
Web["Web app<br/>(Next.js + React UI)"]
Ext["Browser extension<br/>(Send to Readest)"]
WinExt["Windows shell ext<br/>(thumbnail provider)"]
end
subgraph Backend["Readest backend (Next.js routes + Cloudflare Worker)"]
AppApi["src/app/api/*<br/>(App Router)"]
PagesApi["src/pages/api/*<br/>(Pages Router)"]
RuntimeCfg["/runtime-config.js<br/>(server-injected config)"]
Worker["workers/send-email<br/>(Cloudflare Worker)"]
end
subgraph Cloud["External services"]
Supabase["Supabase<br/>(auth + Postgres)"]
S3["Object storage<br/>(S3 / R2)"]
Stripe["Stripe<br/>(billing)"]
AI["AI providers<br/>(OpenAI / Ollama / ...)"]
Trans["Translators<br/>(DeepL / Google / Azure / Yandex)"]
Meta["Metadata providers<br/>(Google Books / Open Library)"]
Dict["Dictionary sources<br/>(Wikipedia / Wiktionary / StarDict)"]
OPDS["OPDS catalogs / Calibre"]
Hardcover["Hardcover GraphQL"]
Readwise["Readwise"]
TTS["Edge TTS"]
IAP["Apple / Google IAP"]
end
Desktop --> Backend
Mobile --> Backend
Web --> Backend
Ext --> PagesApi
WinExt -.reads files.-> Desktop
PagesApi --> Supabase
PagesApi --> S3
PagesApi --> Trans
AppApi --> Supabase
AppApi --> Stripe
AppApi --> AI
AppApi --> Meta
AppApi --> OPDS
AppApi --> Hardcover
AppApi --> TTS
AppApi --> IAP
Web -.direct.-> Dict
Desktop -.direct.-> Dict
Mobile -.direct.-> Dict
Web -.direct.-> Readwise
Desktop -.direct.-> Readwise
```
The `Backend` box is **the same code on all clients**. In the web target it is
deployed as a Cloudflare Worker (via `@opennextjs/cloudflare` and
`wrangler.toml`). In the Tauri targets the same routes are served by a Next.js
runtime, but most clients hit the production deployment over HTTPS.
## 2. Process boundaries
There are three runtimes in play:
```mermaid
flowchart TB
subgraph Browser["Web runtime (browser / Tauri webview)"]
UI["React UI<br/>(src/app, src/components, src/hooks, src/store)"]
Domain["Shared domain layer<br/>(src/services, src/utils, src/libs)"]
Foliate["foliate-js<br/>(packages/foliate-js)"]
SW["Service worker (sw.ts)"]
TursoWasm["Turso WASM<br/>(replica DB in browser)"]
end
subgraph Native["Tauri native host (Rust)"]
TauriCore["src-tauri/src/lib.rs<br/>(commands, dir_scanner, transfer_file, clip_url, discord_rpc)"]
Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, deep-link, opener, updater,<br/>native-bridge, native-tts, turso, webview-upgrade)"]
end
subgraph Server["Next.js server (Worker / Node)"]
Routes["App Router + Pages Router routes"]
Mw["middleware.ts<br/>(CORS + COOP/COEP)"]
RuntimeRoute["app/runtime-config.js<br/>(server-rendered config script)"]
end
UI --> Domain
Domain --> Foliate
UI --> SW
Domain --> TursoWasm
Domain -- "@tauri-apps/api invoke()" --> TauriCore
TauriCore --> Plugins
Domain -- "fetch(/api/...)" --> Routes
Browser -- "<script src=/runtime-config.js>" --> RuntimeRoute
Routes --> Mw
```
Three things are worth calling out:
The same `src/services/*` code runs on both sides of the `invoke()` boundary on
desktop/mobile and on both sides of the `fetch()` boundary on web. Which
implementation is picked is decided at runtime by `src/services/environment.ts`
plus the platform-specific `*AppService.ts` (`webAppService`, `nativeAppService`,
`nodeAppService`) — see section 4.
`middleware.ts` does two things and only two things: CORS for `/api/*`, and
`Cross-Origin-Opener-Policy: same-origin` + `Cross-Origin-Embedder-Policy:
require-corp` on every document. The COOP/COEP pair is required so that the
browser exposes `SharedArrayBuffer`, which the Turso WASM thread pool needs in
order to run the in-browser replica database; without those headers
`initThreadPool` hangs.
`/runtime-config.js` is a server route that emits
`window.__READEST_RUNTIME_CONFIG = {...}` as a JavaScript file. It is loaded as
a `<script>` tag from `app/layout.tsx` and `pages/_document.tsx`. This is what
lets a single Docker image be rebranded with a different Supabase project, S3
endpoint, or quota at deploy time without rebuilding — see commit
`9ad43aa8` and the `docker/` directory.
## 3. Frontend architecture
The frontend is a Next.js 16 + React 19 app. It uses both routers:
| Concern | Lives in | Why |
|---|---|---|
| Library, reader, auth, OPDS, send, user pages | `src/app/*` (App Router) | Standard for new pages; supports server components and the runtime-config route. |
| Reader entry by ID list `/reader/[ids]` | `src/pages/reader/[ids].tsx` (Pages Router) | Historical entrypoint; coexists with the App Router reader. |
| Cross-origin isolation document shell | `src/pages/_document.tsx` | Pages Router still owns `<Document>` for COOP/COEP and `runtime-config.js`. |
| HTTP API endpoints | both `src/app/api/*` and `src/pages/api/*` | Mix of new App Router routes and legacy Pages Router routes. |
### 3.1 UI module map
```mermaid
flowchart TB
Layout["app/layout.tsx<br/>(root shell, runtime-config script, Providers)"]
Library["app/library<br/>(grid, import, sort, OPDS shelf)"]
Reader["app/reader<br/>(views + tooling)"]
Auth["app/auth<br/>(Supabase auth UI)"]
Send["app/send<br/>(send-to-Readest inbox)"]
User["app/user<br/>(account, subscription, settings)"]
Updater["app/updater"]
Offline["app/offline"]
OPDS["app/opds<br/>(catalog browser)"]
Share["app/s, app/o<br/>(share landing pages)"]
Layout --> Library
Layout --> Reader
Layout --> Auth
Layout --> Send
Layout --> User
Layout --> OPDS
subgraph ReaderInternals["app/reader internals"]
ReaderPage["page.tsx"]
ReaderComps["components/*<br/>(BookView, Sidebar, Notebook,<br/>Annotator, FootnotePopup, Translator,<br/>RSVP overlay, AIChat, ParallelView, ...)"]
ReaderHooks["hooks/*<br/>(useFoliateEvents, useScrollHandler,<br/>useProgressSync, useAnnotations, ...)"]
ReaderUtils["utils/*"]
end
Reader --> ReaderInternals
subgraph Shared["Shared UI primitives"]
Components["components/*<br/>(Button, Dialog, Menu, Toast,<br/>BookCover, AppLockScreen, ...)"]
Settings["components/settings/*<br/>(Layout/Font/Color/Custom panels)"]
Assistant["components/assistant/*<br/>(AI chat composer)"]
CmdPalette["components/command-palette"]
end
```
The biggest UI cluster by far is `app/reader`: roughly 80 components and 30
hooks coordinating Foliate-based rendering, annotations, footnote popovers, the
notebook side panel, parallel view, RSVP, AI chat, translator overlays,
search, TTS, and the settings panels under `components/settings`.
### 3.2 State (Zustand)
Frontend state is split across single-purpose Zustand stores in `src/store`.
Each store maps to a clearly delimited concern, which keeps the reader from
collapsing into one mega-context:
```
libraryStore -> books, folders, selection, sort
bookDataStore -> per-book data (TOC, annotations, locations)
readerStore -> active views, layout, ribbon state
parallelViewStore -> two-pane reading
notebookStore -> notebook side panel
settingsStore -> user/app settings
themeStore -> light/dark/atmosphere
sidebarStore -> sidebar visibility/width
trafficLightStore -> macOS traffic-light positioning
appLockStore -> app PIN lock
deviceStore -> device profile
transferStore -> in-flight uploads/downloads
aiChatStore -> AI chat sessions
proofreadStore -> proofread side flow
atmosphereStore -> ambient overlay
customDictionaryStore / customFontStore /
customTextureStore / customOPDSStore -> user-imported assets
```
### 3.3 In-browser book engine
EPUB / MOBI / KF8 / FB2 / CBZ / TXT / PDF parsing and rendering is **not**
hand-rolled in this repo. The reader sits on top of `packages/foliate-js`, a
forked copy of the Foliate JS engine. Readest's reader code in `app/reader` and
the adapters under `src/services/annotation`, `src/services/nav`,
`src/services/transformers`, and `src/services/rsvp` wrap that engine and add
features (annotations sync, navigation, content transforms, vertical/Warichu
support, classic mode overlays, etc.).
PDF rendering goes through `pdfjs-dist`, which is copied into
`public/vendor/pdfjs` at build time (`pnpm setup-pdfjs`). Chinese conversion
uses `simplecc-wasm` (`public/vendor/simplecc`), and Chinese segmentation uses
`jieba-wasm` (`public/vendor/jieba`).
### 3.4 Service worker and offline
`src/sw.ts` is a Serwist service worker that gives the web build offline
support: cached static assets, cached API responses for read-only data, and an
offline route at `/offline`.
## 4. The platform abstraction (`AppService`)
The single most important abstraction in the codebase is
`src/services/appService.ts`. Every piece of code that touches "the platform"
(file system, native dialogs, shell open, native TTS, IAP, dir scanning,
deep links, etc.) goes through an `AppService` interface. There are three
implementations:
```mermaid
flowchart LR
Caller["UI code, hooks, services"]
AppSvc["AppService interface<br/>(services/appService.ts)"]
Native["nativeAppService.ts<br/>(Tauri desktop + mobile)"]
Web["webAppService.ts<br/>(browser / web build)"]
Node["nodeAppService.ts<br/>(Node tooling, tests, CLI)"]
Caller --> AppSvc
AppSvc --> Native
AppSvc --> Web
AppSvc --> Node
Native -- "@tauri-apps/api invoke()" --> Rust["src-tauri Rust commands"]
Native --> Plugins["Tauri plugins<br/>(fs, dialog, http, oauth, native-bridge, native-tts, turso)"]
Web --> Browser["browser APIs (File, IndexedDB, fetch)"]
Web --> RemoteAPI["fetch() to /api/*"]
Node --> Fs["node:fs, node:path"]
```
`environment.ts` decides at runtime which implementation to mount, based on the
build target (`NEXT_PUBLIC_APP_PLATFORM`) and runtime detection (`window`,
Tauri injection). Most callers in the codebase do
`const appService = useEnv().appService` and never know which one they got.
The same pattern repeats for the database layer in `src/services/database`:
`webDatabaseService` (browser via Turso WASM), `nativeDatabaseService` (Tauri
via the `tauri-plugin-turso` plugin), and `nodeDatabaseService` (Node, used by
tests). All three share `migrate.ts` and `migrations/*`.
This is why most domain code in `src/services` looks platform-agnostic — the
platform difference has been pushed to a small number of seams.
## 5. Backend (Next.js routes)
There are two route trees because of historical mix between App Router and
Pages Router. The split is pragmatic, not load-bearing: new routes go to
`src/app/api`, legacy/sync/storage live in `src/pages/api`.
### 5.1 Pages Router endpoints (`src/pages/api`)
These are the long-standing server endpoints around sync, storage, and email:
```
sync.ts -> KOReader-compatible sync client (`KOSyncClient`)
kosync.ts -> KOSync legacy bridge
sync/replicas.ts -> replica sync upload/download (encrypted blobs)
sync/replica-keys.ts -> replica key bootstrap
storage/upload.ts -> presigned upload to S3/R2 for book bytes
storage/download.ts -> presigned download
storage/list.ts -> list user's objects
storage/delete.ts -> delete a single object
storage/purge.ts -> bulk wipe (account deletion path)
storage/stats.ts -> per-user usage/quotas
send/inbox.ts -> "Send to Readest" inbox listing
send/inbox/* -> inbox item operations
send/address.ts -> per-user inbox address resolver
send/fetch-url.ts -> server-side URL fetcher for "send a link"
send/senders.ts -> sender allowlist
deepl/translate.ts -> DeepL translation proxy (hides API key)
user/delete.ts -> account deletion
```
The storage layer talks to S3-compatible storage through `src/utils/s3.ts`,
which honors a `S3_PUBLIC_ENDPOINT` distinct from the internal endpoint so
docker-compose deployments can route browsers through one origin and the
server through another.
### 5.2 App Router endpoints (`src/app/api`)
Newer endpoints, grouped by domain:
```
ai/chat -> streaming AI chat (Vercel AI SDK)
ai/embed -> embeddings for in-book RAG
metadata/search -> metadata lookup (Google Books / Open Library)
opds/proxy -> CORS-friendly OPDS proxy
tts/edge -> Edge TTS streaming
hardcover/graphql -> Hardcover GraphQL relay
stripe/checkout -> create checkout session
stripe/portal -> billing portal redirect
stripe/plans -> plan listing
stripe/check -> subscription state
stripe/webhook -> Stripe webhook handler
google/iap-verify -> Google Play IAP verification
apple/iap-verify -> App Store IAP verification
share/* -> share-link landing + read-only render
```
### 5.3 Workers
`apps/readest-app/workers/send-email` is a separate Cloudflare Worker
(deployed independently from the main app) responsible for the "Send to Readest
by email" path. It receives mail, normalizes attachments, and drops items into
the user's inbox so that the in-app `Send` page can pick them up via the
`/api/send/inbox` endpoints.
### 5.4 Runtime config
`src/app/runtime-config.js/route.ts` is a server route that builds a small JSON
object — `supabaseUrl`, `supabaseAnonKey`, `apiBaseUrl`, `objectStorageType`,
`storageFixedQuota`, `translationFixedQuota` — from `process.env` at request
time and serializes it as a JS payload. The client reads it through
`getRuntimeConfig()` in `src/services/runtimeConfig.ts` (browser) or
`getServerRuntimeConfig()` (server). This is the mechanism that makes the same
prebuilt Docker image rebrandable per deployment.
## 6. Cross-cutting subsystems
These don't live in one file or one route; they span the frontend, the backend,
and (sometimes) the native shell.
### 6.1 Sync
Two sync paths coexist:
The first is **legacy KOReader-compatible sync** for reading progress,
implemented by `src/services/sync/KOSyncClient.ts` against `pages/api/sync.ts`
and `pages/api/kosync.ts`. It exists for compatibility with KOSync-style
clients.
The second is **replica sync**, the modern path. It encrypts each replica
locally with a passphrase-derived key
(`replicaCryptoMiddleware.ts`, `passphraseGate.ts`), publishes deltas to
`pages/api/sync/replicas.ts`, pulls peer updates, and applies them through
category adapters in `src/services/sync/adapters/*` (annotations, settings,
dictionaries, fonts, textures, OPDS catalogs). The orchestrator is
`replicaSyncManager.ts`. A cursor store (`replicaCursorStore.ts`) tracks "where
I last pulled to" per category so syncs are incremental.
### 6.2 Cloud library
Distinct from replica sync. The cloud library handles **book bytes** (not
metadata):
- import flow: `src/services/ingestService.ts` decides whether a book is
imported as a hash copy under `Books/<hash>/` or kept *in place* at the
user's chosen path (the "in-place" mode added in commit `dd107277`).
- upload: `cloudService.uploadBook` uses the storage layer to push bytes to S3
through `pages/api/storage/upload.ts`.
- download: peers fetch via `pages/api/storage/download.ts`, materializing the
book into `Books/<hash>/` regardless of whether the original device kept it
in-place.
- delete: symmetric local/cloud/both semantics in `cloudService.deleteBook`.
### 6.3 AI / RAG
`src/services/ai` provides the chat and embedding abstraction with provider
adapters, prompt assembly, chunking, retry, and a local AI store. UI lives in
`components/assistant` and the reader-side `app/reader/components/AIChat*`. The
HTTP entrypoints are `src/app/api/ai/chat` (streaming) and
`src/app/api/ai/embed`. The reader can do book-scoped RAG by embedding chapters
locally and querying the embeddings store.
### 6.4 Translation
`src/services/translators` has provider adapters for DeepL, Google, Azure, and
Yandex, plus a preprocess + cache + polish pipeline. DeepL goes through a
server proxy (`pages/api/deepl/translate.ts`) to keep the API key server-side;
the others can hit the providers directly from the client.
### 6.5 TTS
Three TTS backends behind one interface (`src/services/tts`):
- `WebSpeechClient` for browsers,
- `NativeTTSClient` for Tauri via `tauri-plugin-native-tts`,
- `EdgeTTSClient` going through `src/app/api/tts/edge` for streaming Microsoft
Edge voices.
### 6.6 Dictionaries
`src/services/dictionaries` parses StarDict and SLOB packs locally
(`readers/`), and integrates online sources (Wikipedia, Wiktionary,
provider-specific). Lookup goes through a candidate generator + dedup so
clicking a word finds all installed dictionaries and online sources in one
roundtrip.
### 6.7 OPDS / Calibre
`src/services/opds` parses feeds, supports auto-download, and tracks
subscription state. Cross-origin feeds are tunneled through
`src/app/api/opds/proxy`. The library UI surfaces OPDS shelves alongside local
books.
### 6.8 Third-party reading services
Hardcover (`src/services/hardcover` + `src/app/api/hardcover/graphql`) and
Readwise (`src/services/readwise`) integrations let users export reading
progress and highlights.
### 6.9 Annotations
`src/services/annotation` defines the canonical annotation model and provides
adapters: a Foliate adapter (the default in-app representation) and an MR
import/export adapter for moving annotations to and from MoonReader.
### 6.10 RSVP and content transforms
`src/services/rsvp` is the rapid-serial-visual-presentation reading mode.
`src/services/transformers` contains pure functions for language detection,
punctuation normalization, whitespace collapsing, proofread suggestions,
sanitization, footnote rewriting, style injection, traditional/simplified
Chinese conversion (via `simplecc-wasm`), and Warichu (Japanese ruby/rubi)
layout. These are reused by the reader, by RSVP, and by the
"Send to Readest" article-to-EPUB conversion.
### 6.11 Send to Readest
End-to-end pipeline:
1. The browser extension (`apps/readest-app/extension/send-to-readest`) or the
email-to-inbox path (`workers/send-email`) submits a URL or article HTML.
2. `src/services/send/conversion/*` sanitizes the content and converts it to
EPUB (sanitization, TOC building, asset bundling, worker protocol).
3. The result lands in the user's inbox served by `pages/api/send/inbox*`.
4. The `app/send` page or the in-app inbox drainer
(`src/services/send/inboxDrainer.ts`) imports it into the library through
the standard ingest service.
## 7. Native shell (`src-tauri`)
The Tauri host is shared by desktop and mobile. The Rust side (`src-tauri/src`)
is small and focused:
```
lib.rs -> command registration, scope grants, deep links, builder
main.rs -> entrypoint
clip_url.rs -> clipboard URL extraction
dir_scanner.rs -> recursive directory scan (used by library import)
transfer_file.rs -> chunked upload/download for big files
discord_rpc.rs -> Discord Rich Presence (desktop only)
android/, macos/,
windows/ -> per-platform glue
```
Everything else is delegated to **Tauri plugins**, mostly bundled in
`packages/tauri-plugins/plugins`:
- standard plugins: `fs`, `dialog`, `http`, `opener`, `os`, `process`, `shell`,
`cli`, `deep-link`, `haptics`, `log`, `updater`, `websocket`, `oauth`,
`persisted-scope`, `device-info`, `sharekit`
- in-tree custom plugins:
- `tauri-plugin-native-bridge` — Android-side bridges (directory picker
callback, open external URL, etc.)
- `tauri-plugin-native-tts` — native text-to-speech
- `tauri-plugin-turso` — embedded Turso/libSQL database for the native
targets, mirrored by the WASM build used in the browser
- `tauri-plugin-webview-upgrade` — webview update flow on platforms where
that matters
A subtle but important detail in `lib.rs`: `allow_paths_in_scopes` is the
frontend-callable shim that extends both `fs_scope` and `asset_protocol_scope`
**only for paths the Tauri dialog plugin (or persisted-scope on restart)
already granted**. Without that gate, any frontend code path — including a
hypothetical XSS through book content, OPDS HTML, or a compromised dependency —
could grant itself read access to the user's home directory through the asset
protocol. The gate constrains the command to user-picked paths only.
## 8. Build and deploy
```mermaid
flowchart LR
Source["apps/readest-app (single source)"]
subgraph BuildTargets
BWeb["next build<br/>+ @opennextjs/cloudflare<br/>(.env.web)"]
BTauriDesk["next build → tauri build<br/>(.env.tauri)"]
BTauriMob["next build → tauri android/ios build"]
end
subgraph DeployTargets
DCloudflare["Cloudflare Workers<br/>(web.readest.com)"]
DDocker["Docker image<br/>(ghcr.io/readest/readest)"]
DDesktop["dmg / nsis / appimage"]
DMobile["aab / ipa"]
DExt["browser extension package"]
end
Source --> BWeb
Source --> BTauriDesk
Source --> BTauriMob
BWeb --> DCloudflare
BWeb --> DDocker
BTauriDesk --> DDesktop
BTauriMob --> DMobile
Source --> DExt
```
The web target has two delivery modes: a Cloudflare Worker via OpenNext
(`pnpm deploy`) and a self-hostable Docker image built and published from
`.github/workflows/docker-image.yml` to GHCR and Docker Hub. The Docker image
uses `docker/compose.yaml` (pull) plus `docker/compose.build.yaml` (build) and
relies on the runtime-config mechanism described in section 5.4 so a single
prebuilt image can be parameterized with `.env`.
Tauri builds use `dotenv` to switch env files (`.env.tauri`,
`.env.tauri.local`, `.env.apple-*.local`, `.env.ios-*.local`,
`.env.google-play.local`) for code-signing and store-specific configuration.
Mobile and desktop produce installable bundles (dmg, nsis, appimage, aab, ipa).
## 9. Quick rule of thumb
When trying to place a piece of behavior, ask in this order:
Does it talk to a remote service or write to durable shared storage? Then it
ends up in `src/pages/api` or `src/app/api`, possibly fronted by a service
under `src/services`. Does it touch the user's filesystem, native dialogs, the
shell, or system TTS? Then it goes through `appService` and lands in
`nativeAppService` (Tauri commands in `src-tauri/src/lib.rs`) or
`webAppService` (browser equivalent). Does it manipulate book content, render
the reader, or maintain UI state? Then it lives under `src/app/reader`,
`src/components`, `src/hooks`, `src/store`, or one of the reader-side service
folders (`annotation`, `nav`, `rsvp`, `transformers`, `dictionaries`,
`translators`, `tts`). Is it a sync or cloud-library concern? `src/services/sync`
plus the matching API route, or `src/services/cloudService.ts` plus
`pages/api/storage/*`.
If you can answer "which runtime owns this" in one sentence, you've placed the
file correctly. If you can't, it's probably shared and belongs under
`src/services`, `src/utils`, `src/libs`, or `src/types`.
+57
View File
@@ -0,0 +1,57 @@
# Book Config JSON
Each imported book may have a per-book config file at:
```text
<bookHash>/config.json
```
The file is written under the `Books` storage root and uses the same camelCase
keys as the TypeScript `BookConfig` type in `src/types/book.ts`.
## Version
`schemaVersion` identifies the raw `config.json` schema written by Readest.
Current version:
```json
{
"schemaVersion": 1
}
```
Configs written before this field existed are treated as legacy configs and are
loaded as the current version. New writes must include `schemaVersion`.
`schemaVersion` is only for the raw disk JSON file. The cloud sync
`book_configs` table is a normalized sync projection and does not mirror this
field.
## Stable Fields
Version 1 documents these fields as the supported integration surface:
- `schemaVersion`: raw config schema version.
- `bookHash`, `metaHash`: book identity when present.
- `progress`: current page tuple, `[current, total]`.
- `location`: current reading location as CFI.
- `xpointer`: current reading location as XPointer for KOReader interoperability.
- `booknotes`: bookmarks, annotations, and excerpts.
- `rsvpPosition`: RSVP reading position.
- `updatedAt`: last config update timestamp in milliseconds.
`viewSettings` and `searchConfig` are persisted app state. They are partial
overrides and are merged with defaults when Readest loads the config.
## Notes and XPointer Fields
`BookConfig.xpointer` is the current reading location. It was not renamed by
KOReader annotation sync work.
For notes in `booknotes`, Readest stores note ranges with:
- `xpointer0`: start XPointer.
- `xpointer1`: end XPointer, when available.
This distinction matters for integrations reading raw config files: progress
uses `xpointer`, while note ranges use `xpointer0` and `xpointer1`.
+341
View File
@@ -0,0 +1,341 @@
# Readest App Code Layout
This note summarizes the runtime boundaries inside `apps/readest-app`, with two goals:
- explain which directories are server-side, client-side, or mixed
- explain the directory-level role of `apps/readest-app/src/services`
## First: `src-tauri`
`apps/readest-app/src-tauri` is the Tauri native shell layer for all Tauri targets, not just desktop.
- Desktop: Windows, macOS, Linux
- Mobile: Android, iOS
That is visible in `apps/readest-app/src-tauri/tauri.conf.json`, which contains both `bundle.android` and `bundle.iOS` configuration, plus mobile deep-link settings.
So the rough split is:
- `apps/readest-app/src`: the Next.js/React app
- `apps/readest-app/src-tauri`: the native host layer for Tauri desktop and mobile builds
## Directory classification inside `apps/readest-app`
### Mostly server-side directories
- `apps/readest-app/src/app/api`
Next.js App Router server endpoints (`route.ts`). These run on the server / edge runtime, not in the browser.
- `apps/readest-app/src/pages/api`
Next.js Pages Router API endpoints. This is where the classic server handlers live, including sync, storage, send, DeepL, and user endpoints.
- `apps/readest-app/src/app/runtime-config.js`
A server route that emits runtime JavaScript config for the client.
- `apps/readest-app/workers`
Worker-side backend code outside the normal page UI tree. For example, `workers/send-email` is operational backend code.
### Mostly client-side directories
- `apps/readest-app/src/components`
Reusable React UI components.
- `apps/readest-app/src/context`
React context providers and app state wiring.
- `apps/readest-app/src/hooks`
Client-side React hooks.
- `apps/readest-app/src/store`
Frontend state stores.
- `apps/readest-app/src/styles`
Styling, theme assets, and UI presentation helpers.
- `apps/readest-app/src/data`
Static or bundled app data.
- `apps/readest-app/src/i18n`
Internationalization resources and setup.
- `apps/readest-app/src/workers`
Browser worker code used by the frontend.
- `apps/readest-app/public`
Static assets served to the frontend.
- `apps/readest-app/extension`
Browser-extension-specific client code.
- `apps/readest-app/extensions`
Platform integration extensions such as Windows thumbnail support.
### Mixed or shared directories
- `apps/readest-app/src/app`
Mostly frontend routes and UI, but not purely client-side. In Next App Router, `page.tsx`, `layout.tsx`, and related files can mix server rendering and client components. The exception is `src/app/api`, which is server-only.
- `apps/readest-app/src/pages`
Mixed. `src/pages/api` is server-only; `src/pages/reader/[ids].tsx` is frontend page code; `_document.tsx` is server-side document wiring.
- `apps/readest-app/src/services`
Shared domain/service layer. Most of this is not “backend-only”; it contains platform adapters, client logic, network clients, sync logic, and some code that is reused by server routes.
- `apps/readest-app/src/utils`
Shared helpers used by both frontend code and server handlers.
- `apps/readest-app/src/libs`
Shared library code. Some of it is server-oriented, some client-oriented, some neutral.
- `apps/readest-app/src/helpers`
General helper code, usually shared.
- `apps/readest-app/src/types`
Shared type definitions.
- `apps/readest-app/src/__tests__`
Test code covering both client and server behavior.
- `apps/readest-app/e2e`
End-to-end test suite.
- `apps/readest-app/scripts`
Build, release, and maintenance scripts.
- `apps/readest-app/docs`
App-specific documentation.
## `src/app` and `src/pages` at directory level
### `src/app`
- `src/app/api`: server-side HTTP endpoints
- `src/app/auth`: auth pages and auth-related UI/helpers
- `src/app/library`: library UI
- `src/app/o`: frontend route
- `src/app/offline`: frontend offline page
- `src/app/opds`: OPDS browsing UI
- `src/app/reader`: reader UI
- `src/app/runtime-config.js`: server-generated runtime config endpoint
- `src/app/s`: share landing UI
- `src/app/send`: send/import UI
- `src/app/updater`: updater UI
- `src/app/user`: account/subscription/settings UI
So `src/app` is mostly application UI, with one explicitly server-only subtree: `src/app/api`, plus the runtime-config route.
### `src/pages`
- `src/pages/api`: server-side API routes
- `src/pages/reader`: frontend page route(s)
- `src/pages/_app.tsx`: application wrapper for Pages Router
- `src/pages/_document.tsx`: server-side document shell
So `src/pages` is mixed, not purely client-side.
## `src/services` breakdown
The most important point is this:
- `src/services` is mostly a shared application/service layer
- it is not the same thing as “backend code”
- actual HTTP server entrypoints are mainly under `src/pages/api` and `src/app/api`
### Top-level files in `src/services`
- `appService.ts`
Base application service abstraction.
- `nativeAppService.ts`
Native/Tauri-facing app service implementation.
- `nodeAppService.ts`
Node-capable service implementation.
- `webAppService.ts`
Web/browser-oriented service implementation.
- `bookService.ts`
Book-level operations such as covers, metadata shaping, and book-related domain logic.
- `libraryService.ts`
Library management logic.
- `settingsService.ts`
Reading and persisting settings.
- `backupService.ts`
Backup/import-export related logic.
- `cloudService.ts`
Cloud-related app behavior.
- `fontService.ts`
Custom font handling.
- `imageService.ts`
Image-related helper logic.
- `ingestService.ts`
Import / ingest pipeline for incoming content.
- `persistence.ts`
Shared persistence utilities.
- `transformService.ts`
Content transformation entrypoints.
- `commandRegistry.ts`
Command registration / dispatch.
- `transferManager.ts` and `transferMessages.ts`
Transfer pipeline coordination.
- `environment.ts` and `runtimeConfig.ts`
Runtime environment detection and injected runtime configuration.
- `constants.ts` and `errors.ts`
Shared constants and error types.
These top-level files are mostly shared client/application-layer code, with some runtime branching for web, node, and Tauri.
### `src/services/database`
Platform-specific database access and migrations.
- `webDatabaseService.ts`: browser/web DB implementation
- `nodeDatabaseService.ts`: Node-side DB implementation
- `nativeDatabaseService.ts`: native/Tauri DB implementation
- `migrate.ts` and `migrations/`: schema and migration logic
This is shared infrastructure code, not an HTTP backend directory.
### `src/services/sync`
Sync clients and replica-sync orchestration.
- legacy/remote sync client code such as `KOSyncClient.ts`
- replica sync flow: bootstrap, publish, pull, apply, persistence, cursor storage, encryption, and passphrase handling
- adapter subdirectory for sync categories such as dictionary, font, texture, OPDS catalog, and settings
This is mostly client-side sync orchestration talking to backend endpoints like `src/pages/api/sync.ts` and `src/pages/api/sync/replicas.ts`.
### `src/services/send`
“Send to Readest” and content conversion logic.
- `sendAddress.ts`, `devicePrefs.ts`, `inboxDrainer.ts`
- `conversion/`: article/page-to-EPUB conversion pipeline, sanitization, TOC building, asset bundling, and worker protocol
This is mostly application logic used by frontend flows and server endpoints together.
### `src/services/metadata`
Book metadata lookup services.
- provider implementations like Google Books and Open Library
- shared metadata types and orchestration service
This is shared integration logic. Actual HTTP exposure happens via route handlers such as `src/app/api/metadata/search`.
### `src/services/dictionaries`
Dictionary import, parsing, lookup, and provider registry.
- readers/parsers for StarDict, SLOB, and related formats
- provider adapters for dictionary/web/wikipedia/wiktionary sources
- dictionary service, deduplication, content ID, and lookup candidate generation
This is primarily client/application functionality.
### `src/services/annotation`
Annotation models and provider adapters.
- annotation types and normalization
- provider adapters such as Foliate and MR export/import
Mostly shared reader-side logic.
### `src/services/nav`
Navigation, fragments, grouping, locations, and lookup utilities for books.
Mostly client-side reader logic.
### `src/services/opds`
OPDS feed handling and subscription state.
- feed parsing/checking
- auto-download support
- stream and subscription helpers
Mostly frontend/domain logic, sometimes paired with server proxy routes.
### `src/services/translators`
Translation provider integration.
- provider adapters for DeepL, Google, Azure, Yandex
- preprocessing, cache, polish, and translator utilities
Mixed integration code. Some providers are used via server APIs to avoid exposing secrets.
### `src/services/tts`
Text-to-speech abstraction and implementations.
- `WebSpeechClient.ts`: browser TTS
- `NativeTTSClient.ts`: native/Tauri TTS
- `EdgeTTSClient.ts`: remote/provider-backed TTS
- controller/data/types/utilities
Mixed runtime code, mostly used by the reader frontend.
### `src/services/ai`
AI chat/embedding/RAG related abstractions.
- adapters and providers
- prompts, chunking, retry logic, logging
- local AI store and RAG service
Mixed integration code. The services are shared, while actual HTTP endpoints live under `src/app/api/ai`.
### `src/services/hardcover` and `src/services/readwise`
Third-party reading service integrations.
- Hardcover sync client and mapping store
- Readwise client integration
Mostly client/application integration code.
### `src/services/rsvp`
RSVP reader mode logic.
- controller, persistence, utilities, and types
Client-side reading feature code.
### `src/services/transformers`
Text/content transformation modules.
- language, punctuation, whitespace, proofread, sanitization, footnote, style, simplecc, warichu
Shared pure logic, usually frontend-facing but not tied to a single runtime.
## Practical mental model
If you want a fast rule of thumb for this repo, use this:
- HTTP backend entrypoints: `src/pages/api`, `src/app/api`, `workers`
- frontend UI/routes: `src/app` except `api`, plus `src/components`, `src/hooks`, `src/store`
- shared app/domain logic: `src/services`, `src/utils`, `src/libs`, `src/types`
- native host layer for desktop + Android + iOS: `src-tauri`
That model matches the codebase much better than “everything under `src` is client code.”
@@ -31,6 +31,8 @@ style={{
}}
```
For top-anchored slide-in panels (sidebar, notebook), use `getPanelTopInset()` from `src/utils/insets.ts`. It clears the status bar on tablet/desktop and full-height mobile sheets, but stays flush for a partial-height mobile bottom sheet (which doesn't reach the top of the screen). Gating only on `isFullHeightInMobile` is wrong — a non-mobile panel is also top-anchored and would let the status bar obscure its toolbar.
### Bottom Inset Rules
For UI elements anchored to the **bottom** of the screen (footer bars, controls, progress indicators), use `gridInsets.bottom * 0.33` as padding — a fraction of the full inset since bottom bars don't need as much clearance as the home indicator area:
@@ -0,0 +1,613 @@
# Line-Aware Reading Ruler Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the reading ruler snap to the next group of *actual* rendered text lines on each tap/page-change so lines stay centered in the band, eliminating the drift caused by the current arithmetic step.
**Architecture:** Two new pure functions in `src/app/reader/utils/readingRuler.ts` derive line boxes from `progress.range.getClientRects()` and compute a snapped band center. `ReadingRuler.tsx` caches the line boxes per page and calls the snap function from both the tap handler and the page-change auto-move, falling back to the existing arithmetic step when line geometry is unavailable (scrolled mode, fixed-layout, missing range).
**Tech Stack:** TypeScript, React, Vitest, foliate-js paginator (`progress.range`).
**Design spec:** `docs/superpowers/specs/2026-05-29-line-aware-reading-ruler-design.md`
---
## File Structure
- `src/app/reader/utils/readingRuler.ts` (modify) — add `ReadingRulerLineBox` type, `READING_RULER_LINE_PADDING_PX`, `buildLineBoxes`, `snapReadingRulerToLines`. Pure, no DOM.
- `src/__tests__/utils/readingRuler.test.ts` (modify) — unit tests for the two new functions.
- `src/app/reader/components/ReadingRuler.tsx` (modify) — glue: padded band size, per-page line-box cache, snap in tap handler + page-change auto-move, fallbacks.
---
## Task 1: `ReadingRulerLineBox` type, padding constant, and `buildLineBoxes`
**Files:**
- Modify: `src/app/reader/utils/readingRuler.ts`
- Test: `src/__tests__/utils/readingRuler.test.ts`
`buildLineBoxes` converts per-fragment client rects (from `range.getClientRects()`) into sorted visual-line spans along the ruler axis, in container coordinates. It clusters fragments that belong to the same visual line (high overlap on the ruler axis) and maps coordinates exactly as the existing auto-move does:
- horizontal: span = `[rect.top - containerRect.top, rect.bottom - containerRect.top]`
- vertical-rl (`rtl=true`): span = `[containerRect.right - rect.right, containerRect.right - rect.left]`
- vertical-lr (`rtl=false`): span = `[rect.left - containerRect.left, rect.right - containerRect.left]`
- [ ] **Step 1: Write the failing tests**
Add these imports and tests to `src/__tests__/utils/readingRuler.test.ts`. Update the existing top import block to also import the new symbols:
```typescript
import {
buildLineBoxes,
calculateReadingRulerSize,
clampReadingRulerPosition,
FIXED_LAYOUT_READING_RULER_LINE_HEIGHT,
getReadingRulerMoveDirection,
READING_RULER_LINE_PADDING_PX,
snapReadingRulerToLines,
stepReadingRulerPosition,
} from '@/app/reader/utils/readingRuler';
```
Then add a new `describe` block at the end of the file:
```typescript
type RectLike = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
const rect = (top: number, left: number, height: number, width: number): RectLike => ({
top,
left,
bottom: top + height,
right: left + width,
height,
width,
});
const container = { top: 0, left: 0, right: 300, bottom: 400 };
describe('buildLineBoxes', () => {
it('clusters horizontal fragments into one box per visual line', () => {
const rects = [
rect(0, 10, 16, 50), // line 1, fragment A
rect(0, 60, 16, 40), // line 1, fragment B (same vertical band)
rect(20, 10, 16, 80), // line 2
];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 20, end: 36 },
]);
});
it('ignores zero-size rects', () => {
const rects = [rect(0, 10, 16, 50), rect(20, 0, 0, 0), rect(40, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container)).toEqual([
{ start: 0, end: 16 },
{ start: 40, end: 56 },
]);
});
it('returns sorted boxes even when rects are out of order', () => {
const rects = [rect(40, 10, 16, 50), rect(0, 10, 16, 50), rect(20, 10, 16, 50)];
expect(buildLineBoxes(rects, false, false, container).map((b) => b.start)).toEqual([0, 20, 40]);
});
it('maps vertical-rl columns as distance from the right edge', () => {
// container.right = 300; a column at left=260,right=276 -> [300-276, 300-260] = [24, 40]
const rects = [rect(0, 260, 200, 16)];
expect(buildLineBoxes(rects, true, true, container)).toEqual([{ start: 24, end: 40 }]);
});
it('maps vertical-lr columns as distance from the left edge', () => {
const rects = [rect(0, 24, 200, 16)];
expect(buildLineBoxes(rects, true, false, container)).toEqual([{ start: 24, end: 40 }]);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: FAIL — `buildLineBoxes`, `READING_RULER_LINE_PADDING_PX`, `snapReadingRulerToLines` are not exported (also a build/type error on the import).
- [ ] **Step 3: Implement the type, constant, and `buildLineBoxes`**
In `src/app/reader/utils/readingRuler.ts`, add below the existing `FIXED_LAYOUT_READING_RULER_LINE_HEIGHT` constant (line 3):
```typescript
// Extra band height (px) added so the centered lines clear the band edges.
export const READING_RULER_LINE_PADDING_PX = 6;
export interface ReadingRulerLineBox {
start: number;
end: number;
}
type RulerRect = {
top: number;
bottom: number;
left: number;
right: number;
width: number;
height: number;
};
type RulerContainerRect = { top: number; left: number; right: number };
/**
* Convert per-fragment client rects into sorted visual-line spans along the
* ruler axis, in container coordinates. Fragments that overlap by more than
* half of the smaller fragment on the ruler axis are treated as one line.
*/
export const buildLineBoxes = (
rects: RulerRect[],
isVertical: boolean,
rtl: boolean,
containerRect: RulerContainerRect,
): ReadingRulerLineBox[] => {
const spans: ReadingRulerLineBox[] = [];
for (const r of rects) {
if (!r || r.width <= 0 || r.height <= 0) continue;
let start: number;
let end: number;
if (isVertical) {
if (rtl) {
start = containerRect.right - r.right;
end = containerRect.right - r.left;
} else {
start = r.left - containerRect.left;
end = r.right - containerRect.left;
}
} else {
start = r.top - containerRect.top;
end = r.bottom - containerRect.top;
}
if (end < start) [start, end] = [end, start];
spans.push({ start, end });
}
spans.sort((a, b) => a.start - b.start || a.end - b.end);
const lines: ReadingRulerLineBox[] = [];
for (const span of spans) {
const current = lines[lines.length - 1];
if (current) {
const overlap = Math.min(current.end, span.end) - Math.max(current.start, span.start);
const minHeight = Math.min(current.end - current.start, span.end - span.start);
if (overlap > 0.5 * minHeight) {
current.start = Math.min(current.start, span.start);
current.end = Math.max(current.end, span.end);
continue;
}
}
lines.push({ start: span.start, end: span.end });
}
return lines;
};
```
- [ ] **Step 4: Run tests to verify the `buildLineBoxes` tests pass**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: the `buildLineBoxes` describe block PASSES. (The `snapReadingRulerToLines` import still makes the file fail to compile — that's fixed in Task 2. If the runner refuses to run due to the missing export, temporarily comment out the `snapReadingRulerToLines` import line to confirm, then restore it.)
- [ ] **Step 5: Commit**
```bash
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
git commit -m "feat(ruler): add buildLineBoxes for line-aware ruler geometry"
```
---
## Task 2: `snapReadingRulerToLines`
**Files:**
- Modify: `src/app/reader/utils/readingRuler.ts`
- Test: `src/__tests__/utils/readingRuler.test.ts`
Given the current band center, viewport dimension, padded band size, line count, direction, and the line boxes, return the next band center (px) centered on the next `lines`-line block — or `null` when there is no next group (caller falls back to a page flip).
- [ ] **Step 1: Write the failing tests**
Append to `src/__tests__/utils/readingRuler.test.ts`:
```typescript
describe('snapReadingRulerToLines', () => {
// 10 lines, each 16px tall, 20px apart, starting at 0.
const evenBoxes = [0, 20, 40, 60, 80, 100, 120, 140, 160, 180].map((s) => ({
start: s,
end: s + 16,
}));
it('returns null when there are no line boxes', () => {
expect(snapReadingRulerToLines(100, 400, 40, 2, 'forward', [])).toBeNull();
});
it('advances forward to the next block of N lines, centered on the block', () => {
// band center 20 -> band [0,40]; next group starts at line index 2 (start 40),
// block = lines[2..3] => [40, 76], center = 58.
expect(snapReadingRulerToLines(20, 400, 40, 2, 'forward', evenBoxes)).toBe(58);
});
it('moves backward to the previous block of N lines, centered on the block', () => {
const boxes = [40, 60, 80, 100, 120, 140, 160, 180, 200, 220].map((s) => ({
start: s,
end: s + 16,
}));
// band center 200 -> band [180,220]; last line fully above is index 6 (end 176),
// block = lines[5..6] => [140, 176], center = 158.
expect(snapReadingRulerToLines(200, 400, 40, 2, 'backward', boxes)).toBe(158);
});
it('returns null at the bottom boundary so the page can flip', () => {
const boxes = [
{ start: 0, end: 16 },
{ start: 20, end: 36 },
];
// band center 100 -> band [80,120]; no line starts below -> null.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBeNull();
});
it('returns null at the top boundary so the page can flip', () => {
const boxes = [
{ start: 80, end: 96 },
{ start: 100, end: 116 },
];
// band center 100 -> band [80,120]; no line ends above -> null.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'backward', boxes)).toBeNull();
});
it('clamps the snapped center so the band stays inside the viewport', () => {
const boxes = [{ start: 180, end: 196 }];
// forward target block center = 188, but dimension 200 / size 40 clamps to 180.
expect(snapReadingRulerToLines(100, 200, 40, 2, 'forward', boxes)).toBe(180);
});
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: FAIL — `snapReadingRulerToLines is not a function` (export missing).
- [ ] **Step 3: Implement `snapReadingRulerToLines`**
In `src/app/reader/utils/readingRuler.ts`, add after `stepReadingRulerPosition`:
```typescript
const clampCenterPx = (center: number, dimension: number, rulerSize: number): number => {
const half = rulerSize / 2;
if (half * 2 >= dimension) return dimension / 2;
return Math.max(half, Math.min(dimension - half, center));
};
/**
* Snap the ruler band to the next/previous block of `lines` real text lines,
* centered on that block. Returns the new band center in px, or null when there
* is no next group in the given direction (the caller then flips the page).
*/
export const snapReadingRulerToLines = (
currentCenterPx: number,
dimension: number,
rulerSize: number,
lines: number,
direction: 'backward' | 'forward',
lineBoxes: ReadingRulerLineBox[],
): number | null => {
if (lineBoxes.length === 0 || dimension <= 0) return null;
const count = Math.max(1, Math.floor(lines));
const heights = lineBoxes
.map((b) => b.end - b.start)
.filter((h) => h > 0)
.sort((a, b) => a - b);
const medianHeight = heights.length ? heights[Math.floor(heights.length / 2)] : 0;
const eps = medianHeight * 0.3;
const half = rulerSize / 2;
const bandStart = currentCenterPx - half;
const bandEnd = currentCenterPx + half;
if (direction === 'forward') {
const startIdx = lineBoxes.findIndex((b) => b.start >= bandEnd - eps);
if (startIdx === -1) return null;
const endIdx = Math.min(startIdx + count - 1, lineBoxes.length - 1);
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
return clampCenterPx(center, dimension, rulerSize);
}
let endIdx = -1;
for (let i = lineBoxes.length - 1; i >= 0; i--) {
if (lineBoxes[i].end <= bandStart + eps) {
endIdx = i;
break;
}
}
if (endIdx === -1) return null;
const startIdx = Math.max(endIdx - count + 1, 0);
const center = (lineBoxes[startIdx].start + lineBoxes[endIdx].end) / 2;
return clampCenterPx(center, dimension, rulerSize);
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `pnpm test src/__tests__/utils/readingRuler.test.ts`
Expected: PASS (all describe blocks, including the original ones).
- [ ] **Step 5: Commit**
```bash
git add apps/readest-app/src/app/reader/utils/readingRuler.ts apps/readest-app/src/__tests__/utils/readingRuler.test.ts
git commit -m "feat(ruler): add snapReadingRulerToLines for line-aware stepping"
```
---
## Task 3: Wire line-aware snapping into `ReadingRuler.tsx`
**Files:**
- Modify: `src/app/reader/components/ReadingRuler.tsx`
Glue the pure functions in: padded band size for snap-capable books, a per-page line-box cache, and snapping in both the tap handler and the page-change auto-move, with the existing arithmetic path as fallback.
This task is verified by `pnpm lint` + `pnpm test` (the pure logic is fully covered by Tasks 12) and a manual check in the reader, since the behavior depends on live DOM range geometry that unit tests cannot reproduce.
- [ ] **Step 1: Update imports**
In `src/app/reader/components/ReadingRuler.tsx`, change the `@/types/book` import (line 4) from:
```typescript
import { BookFormat, ViewSettings } from '@/types/book';
```
to:
```typescript
import { BookFormat, FIXED_LAYOUT_FORMATS, ViewSettings } from '@/types/book';
```
And change the `../utils/readingRuler` import (lines 12-16) from:
```typescript
import {
calculateReadingRulerSize,
clampReadingRulerPosition,
stepReadingRulerPosition,
} from '../utils/readingRuler';
```
to:
```typescript
import {
buildLineBoxes,
calculateReadingRulerSize,
clampReadingRulerPosition,
READING_RULER_LINE_PADDING_PX,
ReadingRulerLineBox,
snapReadingRulerToLines,
stepReadingRulerPosition,
} from '../utils/readingRuler';
```
- [ ] **Step 2: Compute `supportsLineSnap` and padded `rulerSize`**
Replace line 61:
```typescript
const rulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
```
with:
```typescript
const supportsLineSnap = !viewSettings.scrolled && !FIXED_LAYOUT_FORMATS.has(bookFormat);
const baseRulerSize = calculateReadingRulerSize(lines, viewSettings, bookFormat);
const rulerSize = baseRulerSize + (supportsLineSnap ? READING_RULER_LINE_PADDING_PX : 0);
```
- [ ] **Step 3: Add the line-box cache ref**
After the `currentPositionRef` declaration (line 59), add:
```typescript
const lineBoxesRef = useRef<ReadingRulerLineBox[]>([]);
```
- [ ] **Step 4: Keep the line-box cache in sync per page**
Immediately after the container-size effect (the `useEffect` that ends at line 118, returning `() => resizeObserver.disconnect()`), add a new effect:
```typescript
// Cache the visible line boxes for the current page so taps can snap to real lines.
useEffect(() => {
if (!supportsLineSnap) {
lineBoxesRef.current = [];
return;
}
const range = progress?.range ?? null;
const containerRect = containerRef.current?.getBoundingClientRect();
if (!range || !containerRect) {
lineBoxesRef.current = [];
return;
}
try {
const rects = Array.from(range.getClientRects());
lineBoxesRef.current = buildLineBoxes(rects, isVertical, rtl, containerRect);
} catch {
lineBoxesRef.current = [];
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
progress?.range,
progress?.pageinfo?.current,
containerSize.width,
containerSize.height,
isVertical,
rtl,
supportsLineSnap,
]);
```
- [ ] **Step 5: Snap on page-change auto-move**
Replace the `performAutoMove` function body (lines 193-215) with the version below. It tries the line snap first and keeps the existing first-visible-text offset as a fallback:
```typescript
const performAutoMove = (range: Range | null) => {
const containerRect = containerRef.current?.getBoundingClientRect();
if (!containerRect) return;
const containerDimension = isVertical ? containerRect.width : containerRect.height;
if (containerDimension <= 0) return;
if (supportsLineSnap && range) {
try {
const rects = Array.from(range.getClientRects());
const boxes = buildLineBoxes(rects, isVertical, rtl, containerRect);
lineBoxesRef.current = boxes;
// Align to the first line group from the top of the page.
const snapped = snapReadingRulerToLines(
-rulerSize,
containerDimension,
rulerSize,
lines,
'forward',
boxes,
);
if (snapped != null) {
setRulerPosition((snapped / containerDimension) * 100, true);
return;
}
} catch {
/* fall through to default offset */
}
}
const textPosition = getFirstVisibleTextPosition(range);
// For vertical mode: use marginRight for vertical-rl, marginLeft for vertical-lr
const defaultOffset = isVertical
? rtl
? (viewSettings.marginRightPx ?? 44)
: (viewSettings.marginLeftPx ?? 44)
: (viewSettings.marginTopPx ?? 44);
const offset = textPosition ?? defaultOffset;
const targetPosition = clampPosition(
((offset + rulerSize / 2) / containerDimension) * 100,
containerDimension,
);
setRulerPosition(targetPosition, true);
};
```
Then add `lines` and `supportsLineSnap` to the auto-move effect's dependency array (the array currently ending at lines 232-242). It should read:
```typescript
}, [
progress?.pageinfo?.current,
viewSettings.scrolled,
isVertical,
rtl,
viewSettings.marginTopPx,
viewSettings.marginLeftPx,
viewSettings.marginRightPx,
rulerSize,
lines,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ] **Step 6: Snap in the tap/key move handler**
In the `reading-ruler-move` effect, replace the body from the `const nextPosition = stepReadingRulerPosition(...)` block through the `return true;` (lines 400-412) with:
```typescript
let nextPosition: number;
if (supportsLineSnap && lineBoxesRef.current.length > 0) {
const currentCenterPx = (currentPositionRef.current / 100) * dimension;
const snapped = snapReadingRulerToLines(
currentCenterPx,
dimension,
rulerSize,
lines,
detail.direction,
lineBoxesRef.current,
);
// No next line group in this direction: let the page flip instead.
if (snapped == null) return false;
nextPosition = (snapped / dimension) * 100;
} else {
nextPosition = stepReadingRulerPosition(
currentPositionRef.current,
dimension,
rulerSize,
detail.direction,
);
}
if (Math.abs(nextPosition - currentPositionRef.current) < 0.001) {
return false;
}
setRulerPosition(nextPosition, true);
return true;
```
Then add `lines` and `supportsLineSnap` to that effect's dependency array (currently line 419) so it reads:
```typescript
}, [
bookKey,
containerSize.height,
containerSize.width,
isVertical,
lines,
rulerSize,
supportsLineSnap,
setRulerPosition,
]);
```
- [ ] **Step 7: Type-check and lint**
Run: `pnpm lint`
Expected: PASS (no Biome errors, no tsgo type errors). If tsgo complains that `ReadingRulerLineBox` is unused, confirm Step 3 added the `lineBoxesRef` typed with it.
- [ ] **Step 8: Run the full unit suite**
Run: `pnpm test`
Expected: PASS — no regressions.
- [ ] **Step 9: Manual verification in the reader**
Start the web dev server (`pnpm dev-web`), open a reflowable EPUB, enable the reading ruler (Settings → Color/Layout → Reading Ruler), and confirm:
- Tapping the page advances the band so the next lines sit centered in it, with no manual adjustment needed across many taps.
- At the bottom of a page, a forward tap flips the page and the band lands centered on the first lines of the new page.
- Backward taps and (if available) a vertical-writing-mode book behave symmetrically.
- A fixed-layout PDF still uses the old fixed-step behavior (no errors).
- [ ] **Step 10: Commit**
```bash
git add apps/readest-app/src/app/reader/components/ReadingRuler.tsx
git commit -m "feat(ruler): snap reading ruler to real text lines on tap and page change"
```
---
## Self-Review Notes
- **Spec coverage:** snapping rule (Tasks 12), fixed-size + padding (`READING_RULER_LINE_PADDING_PX`, Task 3 Step 2), reflowable + vertical scope (`buildLineBoxes` mapping + `supportsLineSnap`), fallback matrix (scrolled/fixed-layout/missing range/boundary all route to `stepReadingRulerPosition` or page flip), per-page caching (Task 3 Step 4). All covered.
- **Type consistency:** `ReadingRulerLineBox { start; end }`, `buildLineBoxes(rects, isVertical, rtl, containerRect)`, and `snapReadingRulerToLines(currentCenterPx, dimension, rulerSize, lines, direction, lineBoxes)` are used identically in tests and glue.
- **No placeholders:** every code step contains complete code and exact run commands.
@@ -0,0 +1,557 @@
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a keyboard action (default `Shift+M`, "Text to Speech" section) that persists the sentence TTS is currently reading aloud as a normal highlight using the user's default style/color — eyes-off, silent, idempotent (skip duplicates).
**Architecture:** Connect the two existing owners through the app event bus. `TTSController` is the only place that knows both `view.tts` (current sentence Range) and the TTS section index, so it exposes `getSpokenSentence(): { cfi, text } | null`. `Annotator` owns highlight persistence/rendering, so it creates the note. The shortcut → `useBookShortcuts` dispatches `tts-highlight-sentence``useTTSControl` (holds the controller ref) resolves the sentence and dispatches `create-tts-highlight``Annotator` builds/persists/draws the highlight. The bug-prone create-or-skip decision is a pure, unit-tested helper.
**Tech Stack:** TypeScript, React, Zustand, foliate-js, Vitest. Spec: `docs/superpowers/specs/2026-05-30-tts-highlight-current-sentence-design.md`.
**Conventions:**
- Test-first (project rule `.agents/rules/test-first.md`): write the failing test, run it red, implement, run it green.
- Never use the `any` type (`.agents/rules/typescript.md`); the test code below casts mock objects via `as unknown as <Type>`, matching the existing suites.
- Run a single test file with `pnpm test <path>` (no `--`).
- Conventional commits with scope, e.g. `feat(tts): ...`. End every commit message body with:
```
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
```
- Do not push during implementation; commit locally only.
---
## File Structure
| File | Change | Responsibility |
| ---- | ------ | -------------- |
| `src/app/reader/utils/annotatorUtil.ts` | Modify | Add pure `buildTTSSentenceHighlight()` (create-or-skip decision + BookNote assembly) |
| `src/__tests__/utils/annotator-util.test.ts` | Modify | Unit tests for `buildTTSSentenceHighlight` |
| `src/services/tts/TTSController.ts` | Modify | Add `getSpokenSentence()` resolver |
| `src/__tests__/services/tts-controller.test.ts` | Modify | Unit tests for `getSpokenSentence` |
| `src/helpers/shortcuts.ts` | Modify | Register `onTTSHighlightSentence` default binding |
| `src/app/reader/hooks/useBookShortcuts.ts` | Modify | Handler that dispatches `tts-highlight-sentence` |
| `src/app/reader/hooks/useTTSControl.ts` | Modify | Resolve sentence via controller, relay `create-tts-highlight` |
| `src/app/reader/components/annotator/Annotator.tsx` | Modify | Handle `create-tts-highlight`: build/persist/draw |
---
## Task 1: Pure helper `buildTTSSentenceHighlight`
**Files:**
- Modify: `src/app/reader/utils/annotatorUtil.ts`
- Test: `src/__tests__/utils/annotator-util.test.ts`
- [ ] **Step 1: Write the failing test**
Append to `src/__tests__/utils/annotator-util.test.ts`. Also add `buildTTSSentenceHighlight` to the existing import from `@/app/reader/utils/annotatorUtil` and `HighlightStyle`, `HighlightColor` to the existing `@/types/book` import:
```ts
describe('buildTTSSentenceHighlight', () => {
const params = {
cfi: 'epubcfi(/6/4!/4/10,/1:0,/1:42)',
text: 'A spoken sentence.',
style: 'highlight' as HighlightStyle,
color: 'yellow' as HighlightColor,
page: 7,
};
it('builds an annotation BookNote when none exists at the cfi', () => {
const note = buildTTSSentenceHighlight([], params, 1000);
expect(note).not.toBeNull();
expect(note).toMatchObject({
type: 'annotation',
cfi: params.cfi,
text: params.text,
style: 'highlight',
color: 'yellow',
page: 7,
note: '',
createdAt: 1000,
updatedAt: 1000,
});
expect(typeof note!.id).toBe('string');
expect(note!.id.length).toBeGreaterThan(0);
});
it('returns null (skip) when a live annotation already exists at the cfi', () => {
const existing: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([existing], params, 1000)).toBeNull();
});
it('builds when the only note at the cfi is soft-deleted', () => {
const deleted: BookNote = {
id: 'a1',
type: 'annotation',
cfi: params.cfi,
style: 'highlight',
color: 'red',
text: params.text,
note: '',
createdAt: 1,
updatedAt: 1,
deletedAt: 5,
};
expect(buildTTSSentenceHighlight([deleted], params, 1000)).not.toBeNull();
});
it('builds when the note at the cfi is a non-annotation (bookmark)', () => {
const bookmark: BookNote = {
id: 'b1',
type: 'bookmark',
cfi: params.cfi,
note: '',
createdAt: 1,
updatedAt: 1,
};
expect(buildTTSSentenceHighlight([bookmark], params, 1000)).not.toBeNull();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
Expected: FAIL — `buildTTSSentenceHighlight is not a function` / import error.
- [ ] **Step 3: Implement the helper**
In `src/app/reader/utils/annotatorUtil.ts`, extend the top imports and add the function. Change the `@/types/book` import line to include `HighlightStyle`, and add `uniqueId`:
```ts
import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book';
import { uniqueId } from '@/utils/misc';
```
Add at the end of the file:
```ts
/**
* Build a persistent highlight BookNote for a TTS-spoken sentence, or return
* `null` when one already exists at the same CFI (idempotent — pressing the
* hotkey twice on the same sentence must not create a duplicate).
*
* `now` is injected so the result is deterministic for tests. A soft-deleted
* note (`deletedAt`) or a non-annotation note (e.g. a bookmark) at the same CFI
* does not block creation — it mirrors the live-annotation predicate used by
* the selection-based highlight path in Annotator.tsx.
*/
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: {
cfi: string;
text: string;
style: HighlightStyle;
color: HighlightColor;
page?: number;
},
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null;
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test src/__tests__/utils/annotator-util.test.ts`
Expected: PASS (all `buildTTSSentenceHighlight` cases green, existing cases still green).
- [ ] **Step 5: Commit**
```bash
git add src/app/reader/utils/annotatorUtil.ts src/__tests__/utils/annotator-util.test.ts
git commit -m "$(cat <<'EOF'
feat(tts): add buildTTSSentenceHighlight helper for sentence highlights
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: `TTSController.getSpokenSentence()`
**Files:**
- Modify: `src/services/tts/TTSController.ts` (add method near `dispatchSpeakMark`, ~line 565)
- Test: `src/__tests__/services/tts-controller.test.ts`
- [ ] **Step 1: Write the failing test**
Append a new `describe` block inside the top-level `describe('TTSController', ...)` in `src/__tests__/services/tts-controller.test.ts` (e.g. after the `dispatchSpeakMark` block). It reuses the file's existing `controller`, `mockView`, and `createMockView` setup:
```ts
describe('getSpokenSentence', () => {
test('returns the trimmed text and cfi of the current sentence', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' A spoken sentence. ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toEqual({
cfi: 'cfi-current',
text: 'A spoken sentence.',
});
});
test('returns null when TTS is inactive (no view.tts)', () => {
// No initViewTTS: view.tts is null and the section index is -1.
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when there is no current range', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue(undefined),
} as unknown as FoliateView['tts'];
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when getCFI throws', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => 'x' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockImplementation(() => {
throw new Error('cfi failure');
});
expect(controller.getSpokenSentence()).toBeNull();
});
test('returns null when the sentence text is only whitespace', async () => {
await controller.initViewTTS(0);
mockView.tts = {
getLastRange: vi.fn().mockReturnValue({ toString: () => ' ' }),
} as unknown as FoliateView['tts'];
vi.mocked(mockView.getCFI).mockReturnValue('cfi-current');
expect(controller.getSpokenSentence()).toBeNull();
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
Expected: FAIL — `controller.getSpokenSentence is not a function`.
- [ ] **Step 3: Implement the method**
In `src/services/tts/TTSController.ts`, add this public method immediately above `dispatchSpeakMark(mark?: TTSMark)` (~line 565). It performs the same Range→CFI conversion `dispatchSpeakMark` already uses, reading the current sentence Range from the foliate TTS engine and the active TTS section index:
```ts
getSpokenSentence(): { cfi: string; text: string } | null {
const range = this.view.tts?.getLastRange();
if (!range || this.#ttsSectionIndex < 0) return null;
try {
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
const text = range.toString().trim();
if (!cfi || !text) return null;
return { cfi, text };
} catch {
return null;
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `pnpm test src/__tests__/services/tts-controller.test.ts`
Expected: PASS (new cases green, existing cases still green).
- [ ] **Step 5: Commit**
```bash
git add src/services/tts/TTSController.ts src/__tests__/services/tts-controller.test.ts
git commit -m "$(cat <<'EOF'
feat(tts): expose getSpokenSentence on TTSController
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Register the default shortcut binding
**Files:**
- Modify: `src/helpers/shortcuts.ts` (TTS section, after `onTTSGoPreviousParagraph`, ~line 75)
There is no standalone unit test for the static registry; correctness is verified by `pnpm lint` (tsgo derives `ShortcutConfig` from this object) and by the wiring tasks. The new action automatically appears in the keyboard-shortcuts help dialog because its `section` is non-empty.
- [ ] **Step 1: Add the entry**
In `src/helpers/shortcuts.ts`, insert into `DEFAULT_SHORTCUTS` immediately after the `onTTSGoPreviousParagraph` block (line 75):
```ts
onTTSHighlightSentence: {
keys: ['shift+m'],
description: _('Highlight Current Sentence'),
section: 'Text to Speech',
},
```
- [ ] **Step 2: Type-check**
Run: `pnpm lint`
Expected: PASS. `ShortcutConfig` now includes `onTTSHighlightSentence`. (If `useBookShortcuts` is type-checked before Task 4 wires the handler, `useShortcuts` accepts a partial map, so this should not error on its own; if it does, proceed to Task 4 and re-run.)
- [ ] **Step 3: Commit**
```bash
git add src/helpers/shortcuts.ts
git commit -m "$(cat <<'EOF'
feat(tts): add default Shift+M binding for highlight-current-sentence
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Dispatch the shortcut event from `useBookShortcuts`
**Files:**
- Modify: `src/app/reader/hooks/useBookShortcuts.ts` (handler ~after line 301; registration ~line 358)
- [ ] **Step 1: Add the handler**
In `src/app/reader/hooks/useBookShortcuts.ts`, add immediately after `ttsGoPreviousParagraph` (line 301), mirroring `ttsGoNextSentence`:
```ts
const ttsHighlightSentence = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
};
```
- [ ] **Step 2: Register the handler**
In the `useShortcuts({ ... })` map, add after the `onTTSGoPreviousParagraph: ttsGoPreviousParagraph,` line (line 358):
```ts
onTTSHighlightSentence: ttsHighlightSentence,
```
- [ ] **Step 3: Type-check**
Run: `pnpm lint`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/app/reader/hooks/useBookShortcuts.ts
git commit -m "$(cat <<'EOF'
feat(tts): dispatch tts-highlight-sentence from the shortcut handler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Resolve the sentence and relay it from `useTTSControl`
**Files:**
- Modify: `src/app/reader/hooks/useTTSControl.ts` (handler ~after line 81; effect registration lines 103-114)
- [ ] **Step 1: Add the handler**
In `src/app/reader/hooks/useTTSControl.ts`, add after `handleTTSBackward` (line 81), mirroring the bookKey-matched pattern of the existing TTS handlers:
```ts
const handleTTSHighlightSentence = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sentence = ttsControllerRef.current?.getSpokenSentence();
if (!sentence) return;
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
};
```
- [ ] **Step 2: Register/unregister in the existing effect**
In the `useEffect` at lines 103-119, add the `on`/`off` pair alongside the other TTS listeners:
```ts
eventDispatcher.on('tts-speak', handleTTSSpeak);
eventDispatcher.on('tts-stop', handleTTSStop);
eventDispatcher.on('tts-forward', handleTTSForward);
eventDispatcher.on('tts-backward', handleTTSBackward);
eventDispatcher.on('tts-toggle-play', handleTTSTogglePlay);
eventDispatcher.on('tts-highlight-sentence', handleTTSHighlightSentence);
return () => {
eventDispatcher.off('tts-speak', handleTTSSpeak);
eventDispatcher.off('tts-stop', handleTTSStop);
eventDispatcher.off('tts-forward', handleTTSForward);
eventDispatcher.off('tts-backward', handleTTSBackward);
eventDispatcher.off('tts-toggle-play', handleTTSTogglePlay);
eventDispatcher.off('tts-highlight-sentence', handleTTSHighlightSentence);
```
(Leave the existing `ttsControllerRef.current?.shutdown()` cleanup below unchanged.)
- [ ] **Step 3: Type-check**
Run: `pnpm lint`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add src/app/reader/hooks/useTTSControl.ts
git commit -m "$(cat <<'EOF'
feat(tts): resolve spoken sentence and relay create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Create the highlight in `Annotator`
**Files:**
- Modify: `src/app/reader/components/annotator/Annotator.tsx` (import line 50; handler near `handleHighlight` ~line 840; effect lines 535-545)
- [ ] **Step 1: Import the helper**
Change line 50 to add `buildTTSSentenceHighlight`:
```ts
import {
buildTTSSentenceHighlight,
getHighlightColorHex,
removeBookNoteOverlays,
} from '../../utils/annotatorUtil';
```
- [ ] **Step 2: Add the event handler**
Add immediately after `handleHighlight` (after its closing brace, ~line 840). It reads state freshly via store getters (the listener is registered with `[]` deps, so it must not close over render-time `config`/`settings`/`progress`), matching the fresh-read pattern in `onShowAnnotation`:
```ts
const handleCreateTTSHighlight = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string; cfi: string; text: string } | undefined;
if (!detail || detail.bookKey !== bookKey) return;
const { settings } = useSettingsStore.getState();
const style = settings.globalReadSettings.highlightStyle;
const color = settings.globalReadSettings.highlightStyles[style];
const { booknotes: annotations = [] } = getConfig(bookKey)!;
const page = getProgress(bookKey)?.page;
const annotation = buildTTSSentenceHighlight(
annotations,
{ cfi: detail.cfi, text: detail.text, style, color, page },
Date.now(),
);
if (!annotation) return;
annotations.push(annotation);
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((view) => view?.addAnnotation(annotation));
};
```
- [ ] **Step 3: Register/unregister in the existing mount effect**
In the `useEffect` at lines 535-545, add the `on`/`off` pair:
```ts
eventDispatcher.on('export-annotations', handleExportMarkdown);
eventDispatcher.on('clear-annotations', handleClearAnnotations);
eventDispatcher.on('import-annotations', handleImportAnnotations);
eventDispatcher.on('create-tts-highlight', handleCreateTTSHighlight);
return () => {
eventDispatcher.off('export-annotations', handleExportMarkdown);
eventDispatcher.off('clear-annotations', handleClearAnnotations);
eventDispatcher.off('import-annotations', handleImportAnnotations);
eventDispatcher.off('create-tts-highlight', handleCreateTTSHighlight);
};
```
- [ ] **Step 4: Type-check**
Run: `pnpm lint`
Expected: PASS. (`useSettingsStore` is already imported at line 13; `getConfig`, `getProgress`, `getViewsById`, `updateBooknotes`, `saveConfig`, `envConfig` are all already in scope per lines 80-85.)
- [ ] **Step 5: Commit**
```bash
git add src/app/reader/components/annotator/Annotator.tsx
git commit -m "$(cat <<'EOF'
feat(tts): persist current TTS sentence as a highlight on create-tts-highlight
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Full verification
**Files:** none (verification only)
- [ ] **Step 1: Run the full unit suite**
Run: `pnpm test`
Expected: PASS (no regressions; the two new test blocks green).
- [ ] **Step 2: Lint + type-check**
Run: `pnpm lint`
Expected: PASS (Biome + tsgo clean).
- [ ] **Step 3: Manual smoke test (dev web)**
Run: `pnpm dev-web`, open a book, start TTS (`t`), let it read a sentence, then press `Shift+M`. Confirm:
- the spoken sentence gets a persistent highlight in the user's default color/style;
- pressing the hotkey again on the same sentence does **not** add a second highlight;
- pressing it while TTS is stopped does nothing (no error in console);
- the highlight survives reopening the book (persisted), and appears in the notebook/annotations list.
- the action shows up in the keyboard-shortcuts help dialog (`Shift+?`) under "Text to Speech".
- [ ] **Step 4: (Optional) i18n extraction**
The new `_('Highlight Current Sentence')` string uses key-as-content, so tests/lint pass without extraction. If desired, run the project i18n extraction (`/i18n` skill or `pnpm i18n`) to sync locale catalogs; this is not required for verification to pass and may touch unrelated locale files — keep it out of the feature commits if run.
---
## Notes for the implementer
- **No production test seams.** `getSpokenSentence` is tested by setting the private `#ttsSectionIndex` through the public `controller.initViewTTS(0)` path, then overriding `mockView.tts`/`mockView.getCFI` — exactly how the existing `forward`/`backward`/`start` tests in that suite operate.
- **Granularity is always sentence.** All TTS clients report only `'sentence'` from `getGranularities()`, so `view.tts.getLastRange()` is always a sentence Range — no word-vs-sentence branching is needed.
- **Two events, three components, by design.** Only `TTSController` (via `useTTSControl`) can produce the CFI; only `Annotator` owns highlight persistence. The relay mirrors the existing `tts-forward`/`tts-backward` shortcut pattern rather than duplicating annotation logic.
- **bookKey matching everywhere.** Both new handlers compare `detail.bookKey === bookKey`, so split-view (two open books) routes the highlight to the correct book.
```
@@ -0,0 +1,307 @@
# Gesture-Based Brightness Control (iOS / Android)
GitHub issue: https://github.com/readest/readest/issues/3021
## Summary
Add a left-edge vertical swipe gesture that adjusts screen brightness while
reading, without opening the menu. While adjusting, a vertical progress bar with
a Sun icon appears at the left edge to indicate the current brightness level.
The feature is gated to platforms with native brightness control (iOS and
Android — `appService.hasScreenBrightness`). It is on by default but can be
disabled via a setting, works in both paginated and scrolled modes, and persists
the chosen brightness across sessions.
## Locked decisions
- **Enablement**: on by default for iOS/Android, with an opt-out toggle in
**Settings → Behavior → Device**. The toggle doubles as the discoverability
surface and the escape hatch for accidental activation (CEO review, both
voices). New setting: `swipeBrightnessGesture: boolean` (default `true`) in
`SystemSettings`.
- **Persistence**: on release, save `screenBrightness` (0100) and set
`autoScreenBrightness = false`, exactly like the existing menu slider, so the
value survives restart and stays in sync with the slider. Undo path: the menu
slider's "System Screen Brightness" toggle re-enables auto-brightness (CEO
review: an accidental swipe silently disables auto-brightness; the undo must be
documented and reachable).
- **Scope**: core only. One opt-out toggle (above). No sensitivity setting, no
corner choice, no volume gesture, no haptics, no lock.
- **Gesture area**: left **10%** of the view width, in **both** paginated and
scrolled modes.
- **Direction**: swipe up = brighter, swipe down = dimmer. A full view-height
drag spans the full 0→100% range.
## Behavior
1. A touch begins inside the left 10% of the view width.
2. It activates as a brightness gesture once movement becomes dominantly
vertical (`|Δy| > |Δx|`) and passes a ~18px threshold. The threshold is
deliberately above incidental thumb-jitter (CEO review: 10px was too eager
for an always-on edge strip).
3. While active, device brightness updates live (throttled via
`requestAnimationFrame`), and the overlay shows the current level.
4. On release, the value is persisted (`screenBrightness` + `autoScreenBrightness
= false`) and the overlay fades out shortly after.
Brightness mapping: `next = clamp(startBrightness Δy / viewHeight, 0, 1)`,
where `startBrightness` is the device brightness captured at activation.
## Conflict suppression (key design point)
The existing iframe touch listeners (`FoliateViewer.tsx` ~line 326) are passive
and forward events via `postMessage` to `useTouchEvent` / the interceptor chain,
which drives page-flip swipes and the upward-swipe-to-toggle-UI behavior. In
scrolled mode the iframe also scrolls natively on a vertical drag.
Attach a **dedicated capture-phase, non-passive** touch listener on the iframe
`doc`: `addEventListener('touch{start,move,end,cancel}', fn, { capture: true,
passive: false })`. The callback is a parent-realm closure, so it can call the
device store and React state directly — no `postMessage` or interceptor needed.
**Why capture phase (corrected after eng review — this was a bug in the first
draft).** There are *three* independent touch-listener registrants on the same
`doc`: (1) FoliateViewer's own `postMessage` forwarders (`FoliateViewer.tsx:326`,
passive), (2) the Annotator's non-passive selection listeners (`Annotator.tsx:332`),
and (3) **foliate-js's own paginator** (`packages/foliate-js/paginator.js:1034`,
non-passive, bubble-phase), registered during `view.open()` — i.e. *before* any
app-level listener. The paginator's `touchmove` can `preventDefault()`, set
`#touchScrolled`, and `scrollBy()`. A bubble-phase listener registered later
therefore **cannot** `stopImmediatePropagation` the paginator — it already ran.
Both eng voices (Claude + Codex) independently verified this against `paginator.js`.
A **capture-phase** listener fires before every bubble-phase listener regardless
of registration order, so its `stopImmediatePropagation` suppresses paginator,
Annotator, and FoliateViewer handlers alike.
When the gesture is active, each move/end/cancel calls `preventDefault()` +
`stopImmediatePropagation()`. The latter is the *sole* mechanism that suppresses
the conflicting page-flip / upward-swipe-to-toggle-UI (`useIframeEvents.ts:282`
also 10px, vertical, left-edge-inclusive); there is no threshold gap to rely on.
The ~18px activation threshold only reduces *accidental* starts.
**Selection guard.** Before arming, the listener checks `doc.getSelection()`; if a
non-collapsed selection exists, it does not arm (mirrors `paginator.js:1622`). This
keeps a vertical text-selection drag that starts in the left strip from being
hijacked into a brightness change.
**Scrolled-mode timing.** In scrolled mode the paginator does not `preventDefault`
(it early-returns at `paginator.js:1613`); native container scroll is what moves
content. `preventDefault` only takes effect once called, so the pre-activation
travel (≤18px) would scroll before brightness takes over. Decision: see the
"Scrolled-strip reservation" item in the review report — the implementation
`preventDefault`s from the first move of any touch that *armed* in the strip
(reserve the strip), so there is no scroll-then-freeze jump.
Before arming, and for taps / horizontal swipes / touches outside the strip, the
listener does nothing, so normal page-turn taps and swipes are unaffected.
Required test: a short upward flick inside the left 10% must adjust brightness and
never toggle the toolbar (asserted by spying `stopImmediatePropagation` with a
fake paginator listener registered *first*, proving capture-phase suppression).
## Components
### `src/app/reader/utils/brightnessGesture.ts` (pure, unit-tested)
- `isInLeftEdge(x: number, viewWidth: number, edgeRatio = 0.1): boolean`.
**Use `screenX` + the parent `window.innerWidth`, NOT `clientX` /
`documentElement.clientWidth`.** In paginated mode foliate-js lays content out
as side-by-side columns, so the iframe document is many screens wide and
`clientX` is a document coordinate (a left-edge touch on a later page reports a
large `clientX`). `screenX` is the physical screen position; the listener runs
in the parent realm so `window.innerWidth` is the real app viewport. (Matches
how `useIframeEvents` / `usePagination` already do zone detection.)
- `shouldActivate(deltaX: number, deltaY: number, threshold: number): boolean`
— true when `|Δy| >= threshold && |Δy| > |Δx|`.
- `computeBrightness(startPos: number, deltaY: number, viewHeight: number): number`
— works in **perceptual position** space (01) to match the menu slider:
`pos = clamp(startPos deltaY / viewHeight, 0, 1)`, then brightness value =
`positionToValue(pos)`. Reuse the slider's `pow(0.5)` curve from `ColorPanel.tsx`
(extract `valueToPosition` / `positionToValue` into this module so there is one
source of truth — design review, both voices: a linear gesture would land on a
different number than the slider for the same finger travel). Always clamp the
seed to `[0,1]` and never feed the `-1` sentinel into the curve.
Constants: `BRIGHTNESS_GESTURE_EDGE_RATIO = 0.1`,
`BRIGHTNESS_GESTURE_ACTIVATION_PX = 18`.
### `src/app/reader/hooks/useBrightnessGesture.ts`
Inert unless `appService.hasScreenBrightness` AND `settings.swipeBrightnessGesture`.
**Latest-closure ref (`latestRef`).** The listener is attached once per doc (the
`isEventListenersAdded` guard) from a `docLoadHandler` that itself is captured
with a `[view]` dependency — so it sees stale render values. Therefore the
listener must read everything runtime-variable from a single `latestRef` updated
each render (mirrors `handlePageFlipRef` / `useTouchInterceptor`): the live
`swipeBrightnessGesture` toggle, `viewSettings.scrolled` / `.vertical`, and the
seed brightness. It must NOT read values captured in the hook's render closure.
**Seed priming (async-race fix).** On mount (when `hasScreenBrightness`), prime a
`seedBrightnessRef`: if `settings.screenBrightness ≥ 0` use it, else
`await getScreenBrightness()`, clamped to `[0,1]`; fall back to `0.5` if the read
fails or returns `< 0`. Multi-pane coherence: seed each gesture-start from the
**shared** `settings.screenBrightness` (via `latestRef`), not a private per-book
cache, so two grid panes don't drift. A late-resolving seed must not overwrite a
value the user has already adjusted this gesture.
Owns refs: `touchStart`, `armed`, `active`, `startPos`, `rafId`, `hideTimer`,
`seedBrightnessRef`, `latestRef`. Exposes `registerBrightnessListeners(doc)` and
`{ overlayVisible, overlayLevel }`.
Listener logic (capture phase):
- **touchstart**: if `!latestRef.swipeBrightnessGesture` → ignore. If
`doc.getSelection()` is non-collapsed → ignore (selection guard). Else record
start `clientX/clientY`; `armed = isInLeftEdge(...)`.
- **touchmove**: if `armed`:
- scrolled mode → `preventDefault()` from this first move (reserve the strip; no
scroll-then-freeze jump).
- once `active || shouldActivate(...)`: set `active`, `preventDefault()`,
`stopImmediatePropagation()`, compute brightness via the perceptual curve,
coalesce `setScreenBrightness` through a single `requestAnimationFrame`
(store `rafId`, cancel the prior frame), set `{ overlayVisible, overlayLevel }`.
- **touchend / touchcancel**: if `active`: `preventDefault()`,
`stopImmediatePropagation()`, **cancel any pending `rafId`**, apply the final
level deterministically, persist (`saveSysSettings('screenBrightness',
round(value*100))` + `saveSysSettings('autoScreenBrightness', false)`), schedule
the overlay hide (`hideTimer`, ~600ms). Always reset `touchStart/armed/active`.
- **teardown**: on hook unmount, `cancelAnimationFrame(rafId)` and clear
`hideTimer`.
### `src/app/reader/components/BrightnessOverlay.tsx`
A self-contained **capsule** (its own surface — `bg-base-100/90`, `border-base-content/20`)
holding a `PiSun` icon (`react-icons/pi`), a vertical track (`bg-base-content/20`)
filled from the bottom to `overlayLevel` (`bg-base-content`), and a small numeric
`%` label (`Math.round(value*100)`). The capsule surface is required so the
overlay stays legible over any book background (white / sepia / black / image
themes) — a bare bar would vanish on a same-tone page (design review, both voices).
- **Position**: physical `left` + `env(safe-area-inset-left)`, vertically centered
within the inset-aware content box (not the raw viewport, so it never lands under
a half-open FooterBar). `z-[15]` (above the z-10 header/footer/Ribbon tier),
`pointer-events: none`. RTL: keep physical left (user-locked); set `dir="ltr"`
on the capsule so the `%` reads correctly.
- **Timing (color themes)**: fill height has **no CSS transition** (tracks the
finger 1:1); the capsule fades in fast (~100ms), holds ~500ms after release,
fades out ~200ms, then unmounts. Appears immediately on activation, never dims
the Sun icon with level (full opacity at 0%). 0% keeps the track + border + `0%`
visible; 100% fills flush.
- **e-ink (`[data-eink]`)**: `eink-bordered`, no shadow/gradient, 1px border, and
**no continuous animation** — quantize the visual fill to ~10% steps and drop
the fade (show/hide instantly) so the panel repaints a handful of times, not
60×/s. Device brightness still updates live; only the overlay repaint is stepped.
- **Reduced motion** (`prefers-reduced-motion: reduce`): drop the opacity fades,
show/hide instantly (the live fill is functional, not decorative).
- `aria-hidden` (transient; the labeled menu slider is the canonical control).
Positioned `absolute` within the per-book view container (sibling of the iframe in
FoliateViewer), so in a multi-pane grid each book's overlay stays in its own pane.
### `src/app/reader/components/FoliateViewer.tsx`
- `const { registerBrightnessListeners, overlayVisible, overlayLevel } =
useBrightnessGesture(bookKey)`.
- Call `registerBrightnessListeners(detail.doc)` inside the existing
`isEventListenersAdded` block. (Capture-phase registration makes ordering moot,
but keep it in this block so it shares the doc lifecycle.)
- Render `<BrightnessOverlay visible={overlayVisible} level={overlayLevel} />` as a
sibling of the iframe container.
### `src/types/settings.ts` + `src/services/constants.ts`
Add `swipeBrightnessGesture: boolean` to `SystemSettings` (default `true` in
`DEFAULT_SYSTEM_SETTINGS`). Surface the toggle in the settings UI under
**Behavior → Device** (next to the existing "System Screen Brightness" control in
`ControlPanel.tsx`), gated on `appService.hasScreenBrightness`. i18n: add the
label + description strings.
## Testing
The pure helpers are the easy part; the bug-prone logic is in the listener. Both
layers get tests (eng review: the first draft tested only the trivial helpers).
- **Pure helpers** (`brightnessGesture.test.ts`, failing-first):
edge detection at the exact 10% boundary; `shouldActivate` at the 18px boundary
and the `|Δy| == |Δx|` tie; perceptual curve round-trip
(`positionToValue(valueToPosition(v)) ≈ v`); up = brighter sign; `[0,1]` clamp
including an unseeded / `-1` start.
- **Listener-level integration** (`useBrightnessGesture.test.ts`): build a fake
`Document`, call `registerBrightnessListeners`, dispatch synthetic
touchstart/move/end/cancel sequences, and assert:
- capture-phase suppression — register a fake paginator listener *first*; a
left-strip upward flick must call `stopImmediatePropagation` so the fake never
fires (this is the test that fails with bubble-phase, proving the fix);
- horizontal swipe in the strip → not activated (page-flip preserved);
- selection-in-progress in the strip → not hijacked;
- scrolled mode → `preventDefault` called on the armed pre-activation move;
- gating → inert when `!hasScreenBrightness` or `!swipeBrightnessGesture`;
- persistence on end → `saveSysSettings('screenBrightness', …)` +
`autoScreenBrightness=false` (mock `saveSysSettings`/`setScreenBrightness`);
- `rafId` / `hideTimer` cancelled on touchend and unmount.
- **Manual on device**: live feel, overlay appear/fade + e-ink stepped repaint,
scroll reservation in scrolled mode, taps / page-turn swipes still work, and the
Settings → Behavior → Device toggle disables the gesture.
## Out of scope (deferred)
Sensitivity setting, corner/edge choice, right-edge volume gesture, haptic
feedback, gesture lock. These are listed in the issue but explicitly deferred.
## What already exists (reused, not rebuilt)
- `deviceStore.getScreenBrightness/setScreenBrightness` (01) → live brightness.
- `saveSysSettings` + the `screenBrightness` / `autoScreenBrightness` settings →
persistence, identical to the menu slider.
- `ColorPanel.tsx` perceptual `pow(0.5)` curve → extracted and shared.
- `useTouchInterceptor` / `handlePageFlipRef` → latest-closure ref precedent.
- `Annotator.tsx` non-passive doc listener → precedent (we go one further: capture).
- `appService.hasScreenBrightness` → platform gate.
---
## GSTACK REVIEW REPORT (/autoplan — CEO + Design + Eng, dual voices)
Mode: SELECTIVE EXPANSION. Codex + Claude subagent per phase. Premise confirmed
by user. Plan revised in place per the findings below.
### Consensus
- **CEO**: premise CONFIRMED (parity with Moon+/KOReader). Both voices challenged
"no toggle", "left edge", "10px". User decided: add opt-out toggle
(Settings→Behavior→Device, default on); keep left edge; raise to 18px.
- **Design**: e-ink stepped repaint (no continuous animation), self-contained
contrast capsule, perceptual-curve + `%` label, fill-vs-fade timing split,
`z-[15]` + `pointer-events:none`, reduced-motion, RTL physical-left + `dir=ltr`.
- **Eng**: 🔴 the original bubble-phase + `stopImmediatePropagation` suppression
was **refuted by both voices** (foliate-js paginator registers first, bubble) →
**capture-phase** non-passive listener + `touchcancel`. Plus: selection guard,
eager+clamped brightness seed, rAF/timer teardown, shared-settings seed for
multi-pane, iframe-doc coordinate space, listener-level test harness.
### Cross-phase theme
**Gesture-conflict correctness** surfaced in all three phases (CEO flagged the
rationale as unverified; both Eng voices proved it false). Highest-confidence
signal — the capture-phase fix is the single most important change.
### Decision Audit Trail
| # | Phase | Decision | Class | Principle | Rationale |
|---|-------|----------|-------|-----------|-----------|
| 1 | CEO | Ship the gesture | Premise gate | — | User confirmed; parity feature |
| 2 | CEO | Add opt-out toggle (default on) | User challenge | — | User chose: Settings→Behavior→Device |
| 3 | CEO | Keep left edge | User challenge | — | User chose; preserves overlay placement |
| 4 | CEO | Threshold 10→18px | Taste | P5 explicit | Both voices: 10px too eager |
| 5 | CEO | Document auto-brightness undo path | Mechanical | P1 | Silent side-effect needs reachable undo |
| 6 | Eng | Capture-phase listener + touchcancel | Mechanical | P5 | Both voices proved bubble-phase insufficient |
| 7 | Eng | Selection guard before arming | Mechanical | P1 | Prevent selection hijack in strip |
| 8 | Eng | Eager + clamped brightness seed | Mechanical | P1 | Fix async race on default -1/auto |
| 9 | Eng | rAF / hide-timer teardown | Mechanical | P1 | Prevent stale write / leak |
| 10 | Eng | Listener-level integration tests | Mechanical | P1 | Cover the actual bug-prone branches |
| 11 | Design | Perceptual curve reuse + `%` | Mechanical | P4 DRY | One source of truth vs slider |
| 12 | Design | e-ink stepped, no continuous anim | Mechanical | P1 | Project e-ink rules |
| 13 | Design | Contrast capsule surface | Mechanical | P1 | Legible over any book theme |
| T1 | Eng | Scrolled-strip reservation (preventDefault from arm) | **Taste — open** | P5 | See final gate |
@@ -0,0 +1,153 @@
# Line-Aware Reading Ruler — Design
**Date:** 2026-05-29
**Status:** Approved (pending spec review)
## Problem
The reading ruler is a band overlay that helps the user track lines while reading.
Tapping the screen advances the band forward/backward by exactly one ruler height
(`stepReadingRulerPosition` in `src/app/reader/utils/readingRuler.ts`), where the
ruler height is computed arithmetically as `lines × fontSize × lineHeight`.
That arithmetic height rarely matches the *actual* rendered line-to-line distance:
fonts, inline images, headings, ruby text, and CSS overrides all shift real line
positions. The error accumulates across taps, so text drifts out of the band's
center and the user must manually drag the ruler to recenter it — which makes the
feature tedious and undercuts its purpose.
The on-page-change auto-move (`ReadingRuler.tsx`, the `getFirstVisibleTextPosition`
effect) already aligns to the first visible line using real geometry, but
within-page taps do not.
## Goal
Make tap-advance **line-aware**: snap the band to the next group of `lines` *actual*
rendered lines, centered on that block, so text stays centered in the band without
manual adjustment. Drift is eliminated because every step is computed from real
line geometry rather than an accumulating arithmetic offset.
## Decisions (locked)
- **Sizing model:** Fixed band size — keep the configured band height; snap *position*
to real lines rather than dynamically resizing per tap. A small constant padding is
added to the rendered band so the centered lines are fully contained with breathing
room.
- **Scope:** Reflowable horizontal **and** vertical writing mode. Fixed-layout
(PDF/CBZ) and scrolled mode keep the current arithmetic stepping as fallback.
- **Geometry source:** Reuse `progress.range` (Approach A). `progress.range` already
spans the entire first-to-last *visible* text (foliate `#getVisibleRange`,
`packages/foliate-js/paginator.js`), and the existing auto-move already reads
`progress.range.getClientRects()` mapped to container coords. Since a paginated page
does not scroll, those rects are stable for every tap on that page.
## Snapping rule
The band advances by exactly `lines` *real* lines per tap and is centered on that
block's midpoint:
- **Forward:** find the first line whose start is at/after the current band's far edge
(within a small epsilon). Take that line plus the next `lines 1` lines to form a
block `[blockStart, blockEnd]`. The new band center is `(blockStart + blockEnd) / 2`,
clamped to the viewport.
- **Backward:** symmetric — find the `lines` lines ending just before the current
band's near edge and center on their block midpoint.
- **No next group** (already at the last/first line group, or no line geometry):
return `null`. The caller falls back to today's behavior — page flip on tap, or
`stepReadingRulerPosition` where appropriate.
"Center on the block midpoint" guarantees that, regardless of how the configured band
height compares to the real line block, the lines sit centered with equal clipping on
either side in the worst case.
## Architecture
### Pure logic — `src/app/reader/utils/readingRuler.ts`
Two new pure functions (no DOM), unit-tested before implementation:
- `buildLineBoxes(rects, isVertical, rtl, containerRect): LineBox[]`
- Input: plain rect-like objects (from `range.getClientRects()`), orientation flags,
and the container rect for coordinate mapping.
- Clusters per-fragment rects into visual lines by overlap on the cross axis
(horizontal: cluster by vertical overlap → line tops/bottoms; vertical: cluster by
horizontal overlap → column spans). Maps to the ruler axis in container coordinates,
matching the existing auto-move mapping (`rect.top containerRect.top` for
horizontal; distance-from-edge for vertical-rl/lr).
- Returns a sorted array of `{ start, end }` spans along the ruler axis.
- `snapReadingRulerToLines(currentCenterPx, dimension, lines, direction, lineBoxes): number | null`
- Implements the snapping rule above. Returns the next band center in px, or `null`
when there is no next group to advance to.
Existing `clampReadingRulerPosition`, `stepReadingRulerPosition`,
`getReadingRulerMoveDirection`, and `calculateReadingRulerSize` are unchanged and remain
the fallback path.
### Glue — `src/app/reader/components/ReadingRuler.tsx`
- Memoize line boxes per page: rebuild from `progress.range.getClientRects()` when the
page (`progress.pageinfo.current`) / range changes or the container size changes.
- The page-change auto-move effect and the `reading-ruler-move` tap handler both:
1. Compute the snap target via `snapReadingRulerToLines`.
2. If it returns a value, animate the band to it (existing `setRulerPosition(_, true)`).
3. If it returns `null`, fall back to the current logic
(`stepReadingRulerPosition` / no-op so the page flip proceeds).
- Render the band at `rulerSize + READING_RULER_LINE_PADDING_PX` so centered lines are
fully contained. The padding affects only the rendered band and its overlay/clamp
math, not the line-advance computation.
### Fallback matrix
| Condition | Behavior |
| -------------------------------------- | --------------------------------- |
| Reflowable/vertical, line boxes found | Line-aware snap |
| `progress.range` missing / no rects | `stepReadingRulerPosition` (arith)|
| Scrolled mode | Existing behavior (unchanged) |
| Fixed-layout (PDF/CBZ) | `stepReadingRulerPosition` (arith)|
| Snap returns `null` (at boundary) | Page flip / no movement (as today)|
## Testing
Test-first, per project rule. Pure-function unit tests added to the existing
`src/__tests__/utils/readingRuler.test.ts`:
- `buildLineBoxes`: clusters multi-fragment rects into correct line spans; horizontal vs
vertical-rl vs vertical-lr mapping; ignores zero-size rects; sorted output.
- `snapReadingRulerToLines`: forward/backward advance by exactly `lines`; centers on
block midpoint; returns `null` at boundaries; respects clamping; degenerate inputs
(empty `lineBoxes`, `lines` larger than available).
Then implement and verify with `pnpm test` and `pnpm lint`.
## Multi-column layouts (column-aware band)
In paginated layouts that render more than one column (`view.renderer.columnCount > 1`),
clustering visible lines by vertical position alone tangles the two columns once their
line grids drift apart (headings/images), causing the band to jump erratically.
For multi-column horizontal layouts the ruler is **column-aware and spans one column at
a time**:
- `buildReadingRulerColumns(rects, columnCount, overlayWidth, rtl)` groups the
overlay-relative rects into columns by x (bucketed by `overlayWidth / columnCount`),
then into line boxes within each column, returned in reading order.
- Rects are mapped to overlay coordinates with the iframe's frame offset
(`frameRect.left/top`), because paginated multi-column pages shift the iframe far
off-screen horizontally; vertical-only mapping is insufficient here.
- `snapReadingRulerColumns(columnIndex, centerPx, …, columns)` advances within the
active column; at the column's end it moves to the first/last group of the
next/previous column; past the last/first column it returns `null` → page flip.
- The band renders over the active column's horizontal extent; the rest of the page —
**including the inactive column** — is dimmed (top/bottom/left/right dim rects).
- Single column collapses to the full-width band (one column spanning the viewport);
vertical writing mode keeps the flat `buildLineBoxes`/`snapReadingRulerToLines` path.
## Non-goals / YAGNI
- No dynamic per-tap band resizing.
- No new foliate-js APIs or TreeWalker enumeration (Approach B) — reuse `progress.range`.
- No `caretPositionFromPoint` probing (Approach C).
- No changes to scrolled mode or fixed-layout ruler behavior beyond keeping them on the
existing fallback path.
- No new user-facing settings.
@@ -0,0 +1,241 @@
# Hotkey to Highlight the Currently-Spoken TTS Sentence — Design
**Date:** 2026-05-30
**Status:** Approved (pending spec review)
**Issue:** [#4085](https://github.com/readest/readest/issues/4085)
## Problem
While TTS is reading aloud, there is no way to persist the sentence currently
being spoken as a highlight without stopping playback, locating the text
visually, and dragging to select it. That defeats the purpose of hands-free /
eyes-off TTS reading and is inaccessible to users with motor or visual
impairments — exactly the audience that benefits most from "react with a single
key" capture.
Today the only workarounds are to stop TTS and select manually, or to take notes
outside Readest (which then aren't synced/exported with the book).
## Goal
Add a single configurable keyboard action that, while TTS is active, persists the
**sentence currently being read aloud** as a normal highlight — same data model,
persistence, rendering, sync, and export as any other highlight — using the
user's current default highlight style/color. No selection, no looking at the
screen.
## Decisions (locked)
- **Default keybinding:** `Shift+M` ("M for mark"), in the existing
**"Text to Speech"** shortcut section. A single, easy-to-hit key best fits the
eyes-off accessibility motivation. Chosen over the issue's suggested `Ctrl+H`
(already bound to *Highlight Selection*), over `Ctrl/Cmd+Shift+H` (the `cmd+`
variant collides with browser/OS `Cmd+Shift+H` on macOS, and the matcher fires
on any listed key regardless of platform), over `Shift+H` (already an alias for
*Go Back*), and over "unbound by default" (the #3772 customization UI does not
exist yet, so an unbound default would be unreachable by normal users). `Shift+M`
is currently unused, has no browser/OS collision, and behaves identically on all
platforms; it remains customizable once #3772 ships.
- **Repeat press = skip:** If the current sentence is already highlighted, do
nothing. No duplicate, no toggle-delete (an accidental eyes-off repeat must
never destroy a highlight), no color cycling.
- **Silent:** No toast/confirmation. The highlight simply appears.
- **Style/color:** The user's current default —
`settings.globalReadSettings.highlightStyle` and
`highlightStyles[style]` — identical to a default selection highlight.
- **Granularity:** The spoken unit is always a **sentence**. All three TTS
clients (`WebSpeechClient`, `EdgeTTSClient`, `NativeTTSClient`) report only
`'sentence'` granularity via `getGranularities()`, so `view.tts.getLastRange()`
always returns the current sentence Range (never a word).
## Why this wiring
Two existing owners must cooperate, and the design connects them rather than
duplicating their responsibilities:
- Only `TTSController` knows **both** `view.tts` (the foliate TTS engine — source
of the current sentence Range) **and** `#ttsSectionIndex` (required to convert
that Range into a CFI). It already performs exactly this conversion in
`dispatchSpeakMark` (`this.view.getCFI(this.#ttsSectionIndex, range)`).
- Only `Annotator` owns highlight creation — the `BookNote` data model,
idempotency, persistence (`updateBooknotes` + `saveConfig`), rendering
(`view.addAnnotation`), and global-annotation fan-out.
They are connected through the app event bus (`eventDispatcher`), mirroring the
existing `tts-forward` / `tts-backward` / `tts-toggle-play` shortcuts that
`useBookShortcuts` dispatches and `useTTSControl` handles against the controller
ref.
## Data flow
```
User presses Shift+M
→ useBookShortcuts: onTTSHighlightSentence handler
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey })
→ useTTSControl: 'tts-highlight-sentence' handler (owns the TTSController ref)
const sentence = ttsController.getSpokenSentence(); // { cfi, text } | null
if (sentence) eventDispatcher.dispatch('create-tts-highlight',
{ bookKey, ...sentence });
→ Annotator: 'create-tts-highlight' handler
build a BookNote (type 'annotation', default style/color, cfi, text);
skip if a non-deleted annotation already exists at that cfi;
else updateBooknotes + saveConfig + view.addAnnotation(annotation).
```
## Architecture
### 1. Shortcut definition — `src/helpers/shortcuts.ts`
Add to `DEFAULT_SHORTCUTS`:
```ts
onTTSHighlightSentence: {
keys: ['shift+m'],
description: _('Highlight Current Sentence'),
section: 'Text to Speech',
},
```
`ShortcutConfig` is derived from `DEFAULT_SHORTCUTS`, so the type updates
automatically and the action appears in the shortcuts help dialog with no extra
work. When the #3772 customization UI lands, this action is customizable like any
other.
### 2. Current-sentence resolver — `src/services/tts/TTSController.ts`
New public method, the single source of truth for "what sentence is being
spoken right now":
```ts
getSpokenSentence(): { cfi: string; text: string } | null {
const range = this.view.tts?.getLastRange();
if (!range || this.#ttsSectionIndex < 0) return null;
try {
const cfi = this.view.getCFI(this.#ttsSectionIndex, range);
const text = range.toString().trim();
if (!cfi || !text) return null;
return { cfi, text };
} catch {
return null;
}
}
```
Returns `null` when TTS is inactive (`view.tts` is cleared on `shutdown`) or no
sentence is current — the natural no-op gate. Works whether TTS is playing or
paused (a paused controller keeps `view.tts` and the last mark).
### 3. Shortcut handler — `src/app/reader/hooks/useBookShortcuts.ts`
New handler mirroring `ttsGoNextSentence`, registered as `onTTSHighlightSentence`:
```ts
const ttsHighlightSentence = () => {
if (!sideBarBookKey) return;
eventDispatcher.dispatch('tts-highlight-sentence', { bookKey: sideBarBookKey });
};
```
### 4. Resolver glue — `src/app/reader/hooks/useTTSControl.ts`
New handler for `'tts-highlight-sentence'`, registered/cleaned up alongside the
existing TTS event listeners (`tts-forward`, `tts-backward`, …):
```ts
const handleTTSHighlightSentence = (event: CustomEvent) => {
const detail = event.detail as { bookKey: string } | undefined;
if (detail?.bookKey !== bookKey) return;
const sentence = ttsControllerRef.current?.getSpokenSentence();
if (!sentence) return;
eventDispatcher.dispatch('create-tts-highlight', { bookKey, ...sentence });
};
```
### 5. Highlight creation — pure helper + Annotator event handler
The selection path (`handleHighlight`) is selection-coupled and does more than
the TTS path needs (it reads `selection`, computes the CFI from
`selection.range`, updates an existing note, carries the `global` flag, and
drives popup/selection UI state). The TTS path is strictly simpler — create or
skip — so rather than overloading `handleHighlight`, the bug-prone *decision* is
extracted as a pure, unit-testable helper and the Annotator keeps the
React/persistence glue.
**Pure helper — `src/app/reader/utils/annotatorUtil.ts`:**
```ts
export function buildTTSSentenceHighlight(
annotations: BookNote[],
params: { cfi: string; text: string; style: HighlightStyle; color: HighlightColor; page?: number },
now: number,
): BookNote | null {
const exists = annotations.some(
(a) => a.cfi === params.cfi && a.type === 'annotation' && a.style && !a.deletedAt,
);
if (exists) return null; // idempotent: skip duplicates (locked decision)
return {
id: uniqueId(),
type: 'annotation',
note: '',
createdAt: now,
updatedAt: now,
...params,
};
}
```
This mirrors the idempotency predicate already used inline in `handleHighlight`
(`annotations.findIndex(a => a.cfi === cfi && a.type === 'annotation' && a.style
&& !a.deletedAt)`). `now` is injected so the helper is deterministic for tests.
**Annotator — `src/app/reader/components/annotator/Annotator.tsx`:**
A new `'create-tts-highlight'` event handler (bookKey-matched), subscribed
alongside the other `eventDispatcher` listeners: read default `style`/`color`
from `settings.globalReadSettings`, call `buildTTSSentenceHighlight`; if it
returns `null`, do nothing; otherwise push the note, `updateBooknotes` +
`saveConfig`, and `view.addAnnotation(annotation)` for each view — the same
persistence/render calls the selection path makes.
## Edge cases
| Condition | Behavior |
| --------- | -------- |
| TTS not playing / no current sentence | `getSpokenSentence()``null` → silent no-op |
| Sentence already highlighted | idempotency skip — no duplicate (locked decision) |
| TTS reading a not-yet-visible section | CFI uses the TTS section index; note is saved and draws when that section renders (existing `onCreateOverlay` path) |
| Live gray TTS cursor overlay overlaps the new highlight | distinct overlay keys; the gray cursor moves on as TTS advances, leaving the persistent highlight |
| Wrong book (split view) | every handler is bookKey-matched, as the existing TTS handlers are |
## Testing
Test-first, per project rule. Both new units are testable with **no production
test seams**:
- **`TTSController.getSpokenSentence()`** — add to
`src/__tests__/services/tts-controller.test.ts`. The private
`#ttsSectionIndex` is set through the public `await controller.initViewTTS(0)`
path (already exercised in that suite); `mockView.tts.getLastRange` and
`mockView.getCFI` are stubbed via the existing `createMockView` helper.
Cases: returns `{ cfi, text }` (trimmed) when a sentence is active; returns
`null` when `view.tts` is absent (pre-init), when `getLastRange()` is
undefined, when `getCFI` throws, and when the range text is empty/whitespace.
- **`buildTTSSentenceHighlight`** — add to
`src/__tests__/utils/annotator-util.test.ts`. Cases: builds a `BookNote`
(`type: 'annotation'`, given `cfi`/`text`/`style`/`color`/`page`, injected
timestamps) when none exists; returns `null` when a non-deleted annotation
already exists at that `cfi`; still builds when the only match at that `cfi`
is `deletedAt`-soft-deleted or a non-annotation (`bookmark`).
Then verify with `pnpm test` and `pnpm lint`.
## Non-goals / YAGNI
- No toast/confirmation, no haptics (locked: silent).
- No toggle-to-remove and no color cycling on repeat (locked: skip).
- No shortcut-customization UI — that is #3772; this only adds one entry to the
existing registry, which #3772 will expose.
- No word-level highlighting (#4017) — out of scope; granularity is always
sentence in practice.
- No new user-facing settings.
- No change to selection-based highlighting behavior.
+48
View File
@@ -0,0 +1,48 @@
# End-to-end tests
Readest has two end-to-end lanes. They cover different layers and are run
separately.
## Web lane — Playwright
Drives the Next.js **web** build (`pnpm dev-web`) in a real browser. Fast, no
Rust build required. Tests run unauthenticated against a fresh browser
context, so each test starts from an isolated, empty local library.
```bash
pnpm test:e2e:web # run the web e2e suite (auto-starts pnpm dev-web)
pnpm test:e2e:web:headed # run headed, one test at a time, with traces
pnpm test:e2e:web:ui # run in the Playwright UI mode
pnpm test:e2e:web:report # open the last HTML report
```
Every run writes an HTML report to `playwright-report/`; open it with
`pnpm test:e2e:web:report`.
Layout:
| Path | Purpose |
| --------------------------------- | ------------------------------------------------------ |
| `playwright.config.ts` (app root) | Runner config, projects, web server. |
| `e2e/tests/` | Specs (`*.spec.ts`). |
| `e2e/pages/` | Page Object Model — actions/queries, no assertions. |
| `e2e/fixtures/` | Shared fixtures; `fixtures/books/` holds sample books. |
Page objects expose locators and actions; assertions stay in the specs so
failures point at test intent. To add coverage, prefer extending a page
object over inlining selectors in a spec.
The demo-book auto-import (`useDemoBooks`) is suppressed by the base fixture
so the library is deterministic; authenticated/sync flows are out of scope
for this lane until a test account is provisioned.
## Tauri lane — WebdriverIO
Drives the actual **Tauri** desktop shell via `tauri-driver`. Use this for
coverage that depends on the native build (Rust integration, window
management, platform globals).
```bash
pnpm tauri:dev:test # start the Tauri app with the webdriver feature
pnpm test:e2e # run wdio against it (specs: e2e/*.e2e.ts)
```
+49
View File
@@ -0,0 +1,49 @@
import { test as base, expect } from '@playwright/test';
import { LibraryPage } from '../pages/LibraryPage';
import { ReaderPage } from '../pages/ReaderPage';
import { SAMPLE_EPUB } from './books';
type Fixtures = {
/**
* Imports a book (the sample EPUB by default), opens it, and returns a
* {@link ReaderPage} that is ready to interact with.
*/
openBook: (filePath?: string) => Promise<ReaderPage>;
};
/**
* Base test fixture for the web e2e lane.
*
* - Overrides `page` to suppress the demo-book auto-import that `useDemoBooks`
* performs on a fresh web session (see `src/app/library/hooks/useDemoBooks.ts`),
* so every test starts from a deterministic empty library.
* - Adds the `openBook` action fixture so reading/annotation specs do not
* repeat the import-and-open boilerplate.
*/
export const test = base.extend<Fixtures>({
page: async ({ page }, use) => {
await page.addInitScript(() => {
try {
window.localStorage.setItem('demoBooksFetched', 'true');
} catch {
// localStorage may be unavailable in some contexts; ignore.
}
});
await use(page);
},
openBook: async ({ page }, use) => {
await use(async (filePath = SAMPLE_EPUB) => {
const library = new LibraryPage(page);
await library.goto();
await library.importBook(filePath);
await expect(library.bookCards()).toHaveCount(1);
await library.openFirstBook();
const reader = new ReaderPage(page);
await reader.waitForReady();
return reader;
});
},
});
export { expect };
+17
View File
@@ -0,0 +1,17 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const fixturesDir = path.dirname(fileURLToPath(import.meta.url));
/** Synthetic plain-text book — fast, used for basic import coverage. */
export const SAMPLE_TXT = path.join(fixturesDir, 'books/readest-e2e-sample.txt');
/**
* A real EPUB ("Alice's Adventures in Wonderland") from the unit-test
* fixtures. Has multiple chapters and substantial prose, so it exercises
* reading and annotation flows realistically.
*/
export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
@@ -0,0 +1,29 @@
Readest E2E Sample
Chapter One
This is a synthetic plain-text book used by the Readest web end-to-end
test suite. It exists only so that import and reading flows have a small,
deterministic file to exercise. It is not a real publication.
The text below is intentionally repetitive filler. Its only purpose is to
give the paginator enough content to lay out across more than one page so
that page-turning can be exercised by the tests.
Chapter Two
A reader opens a book, and the words arrange themselves into pages. Each
page is a small window onto a longer whole. Turn forward and the window
slides ahead; turn back and it returns to where it was.
Lorem ipsum has long served as placeholder prose, but plain repetition
serves a test suite just as well. Sentences accumulate, paragraphs follow,
and soon there is enough material for the layout engine to work with.
Chapter Three
The end of a sample book is much like its beginning: a few lines of text,
arranged for no one in particular, standing in for the real thing. When
the test completes, this book is discarded and forgotten.
The End
+12
View File
@@ -0,0 +1,12 @@
import type { Page } from '@playwright/test';
/**
* Shared base for page objects.
*
* Page objects expose actions and queries (locators) only assertions live
* in the specs, so a failing assertion points at test intent rather than at a
* helper.
*/
export abstract class BasePage {
constructor(protected readonly page: Page) {}
}
+57
View File
@@ -0,0 +1,57 @@
import type { Locator, Page } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* The library page (`/library`, also rendered at `/`).
*/
export class LibraryPage extends BasePage {
readonly container: Locator;
readonly header: Locator;
readonly bookshelf: Locator;
readonly searchInput: Locator;
readonly clearSearchButton: Locator;
readonly emptyState: Locator;
constructor(page: Page) {
super(page);
this.container = page.locator('[aria-label="Your Library"]');
this.header = page.locator('[aria-label="Library Header"]');
this.bookshelf = page.locator('[aria-label="Bookshelf"]');
this.searchInput = page.locator('.search-input');
this.clearSearchButton = page.locator('[aria-label="Clear Search"]');
this.emptyState = page.getByRole('heading', { name: 'Start your library' });
}
async goto(): Promise<void> {
await this.page.goto('/library');
await this.container.waitFor({ state: 'visible' });
}
/**
* All book cards currently shown in the bookshelf. Book cards are
* `div[role="button"]`; the trailing "+" import tile is a `<button>`, so it
* is naturally excluded.
*/
bookCards(): Locator {
return this.bookshelf.locator('div[role="button"]');
}
/**
* Import a book file via the empty-state "Import Books" button.
*
* The file `<input>` is created off-DOM (see `useFileSelector.selectFileWeb`),
* so a `filechooser` event must be awaited rather than locating an
* `<input type="file">`.
*/
async importBook(filePath: string): Promise<void> {
const importButton = this.page.locator('.hero').getByRole('button', { name: 'Import Books' });
const chooserPromise = this.page.waitForEvent('filechooser');
await importButton.click();
const chooser = await chooserPromise;
await chooser.setFiles(filePath);
}
async openFirstBook(): Promise<void> {
await this.bookCards().first().click();
}
}
+291
View File
@@ -0,0 +1,291 @@
import { type FrameLocator, type Locator, type Page, expect } from '@playwright/test';
import { BasePage } from './BasePage';
/**
* The reader page (`/reader/{ids}` on web).
*
* There is intentionally no `goto()` callers reach the reader by opening a
* book (see the `openBook` fixture), because `/reader` depends on the book
* already being present in local storage.
*
* The header and footer bars are auto-hidden until the book is hovered;
* methods that need them call {@link revealHeader} / {@link revealFooter}.
*/
export class ReaderPage extends BasePage {
readonly viewer: Locator;
readonly foliateView: Locator;
readonly headerBar: Locator;
readonly footerBar: Locator;
readonly sidebar: Locator;
readonly notebook: Locator;
readonly tocItems: Locator;
readonly searchResults: Locator;
readonly annotationPopup: Locator;
readonly noteEditor: Locator;
readonly annotationItems: Locator;
constructor(page: Page) {
super(page);
this.viewer = page.locator('.foliate-viewer').first();
this.foliateView = page.locator('foliate-view').first();
this.headerBar = page.locator('.header-bar').first();
this.footerBar = page.locator('.footer-bar').first();
this.sidebar = page.locator('[role="navigation"][aria-label="Sidebar"]');
this.notebook = page.locator('[role="group"][aria-label="Notebook"]');
this.tocItems = page.locator('.toc-list [role="treeitem"]');
this.searchResults = page.locator('.search-results li[role="button"]');
this.annotationPopup = page.locator('.selection-popup');
this.noteEditor = page.locator('.note-editor-container');
this.annotationItems = page.locator('li.booknote-item[role="button"]');
}
/** Wait until the reader route is active and the book viewer has mounted. */
async waitForReady(): Promise<void> {
await this.page.waitForURL(/\/reader/);
await this.viewer.waitFor({ state: 'visible' });
await this.foliateView.waitFor({ state: 'attached' });
}
// --- chrome (auto-hidden header / footer bars) ---
/** Reveal the header bar by clicking its top hover strip. */
async revealHeader(): Promise<void> {
const box = await this.viewer.boundingBox();
if (box) {
await this.page.mouse.click(box.x + box.width / 2, box.y + 4);
}
}
/** Reveal the footer bar by clicking its bottom hover strip. */
async revealFooter(): Promise<void> {
const box = await this.viewer.boundingBox();
if (box) {
await this.page.mouse.click(box.x + box.width / 2, box.y + box.height - 4);
}
}
// --- pagination & progress ---
async nextPage(): Promise<void> {
await this.page.keyboard.press('ArrowRight');
}
async prevPage(): Promise<void> {
await this.page.keyboard.press('ArrowLeft');
}
/**
* Current reading position as a number parsed from the footer's
* "Reading Progress" label. The label is in the DOM regardless of whether
* the footer is visually revealed, so no reveal is needed.
*/
async readingProgress(): Promise<number> {
const label =
(await this.page
.locator('span[title="Reading Progress"]')
.first()
.getAttribute('aria-label')) ?? '';
const match = label.match(/(\d+(?:\.\d+)?)/);
return match ? Number(match[1]) : Number.NaN;
}
// --- sidebar / table of contents ---
async openSidebar(): Promise<void> {
if (await this.sidebar.isVisible()) return;
await this.revealHeader();
await this.page.locator('button[aria-label="Toggle Sidebar"]').first().click();
await this.sidebar.waitFor({ state: 'visible' });
}
/** Open the sidebar and navigate to the TOC chapter at the given index. */
async openTocChapter(index: number): Promise<void> {
await this.openSidebar();
await this.sidebar.locator('[aria-label="TOC"]').click();
await this.tocItems.nth(index).click();
}
// --- in-book search ---
/** Run an in-book search and return the number of results. */
async search(term: string): Promise<number> {
await this.openSidebar();
await this.page.locator('button[title="Show Search Bar"]').click();
await this.sidebar.locator('input.search-input').fill(term);
await this.searchResults.first().waitFor({ state: 'visible' });
return this.searchResults.count();
}
// --- reader settings ---
/**
* Open the settings dialog, increase the default font size by one step,
* and return the value before and after.
*/
async increaseFontSize(): Promise<{ before: string; after: string }> {
await this.revealHeader();
await this.headerBar.locator('button[aria-label="Font & Layout"]').click();
await this.page.locator('[data-tab="Font"]').click();
const row = this.page.locator('[data-setting-id="settings.font.defaultFontSize"]');
const input = row.locator('input').first();
await input.waitFor({ state: 'visible' });
const before = await input.inputValue();
await row.locator('[aria-label="Increase"]').click();
await expect(input).not.toHaveValue(before);
const after = await input.inputValue();
await this.page.keyboard.press('Escape');
return { before, after };
}
// --- bookmarks ---
get addBookmarkButton(): Locator {
return this.page.locator('button[aria-label="Add Bookmark"]');
}
get removeBookmarkButton(): Locator {
return this.page.locator('button[aria-label="Remove Bookmark"]');
}
// --- text selection & annotations ---
/**
* Find the iframe of the on-screen book section.
*
* The reader prerenders adjacent sections into separate iframes, so this
* scans every `.foliate-viewer iframe` and returns the one holding a `<p>`
* whose bounding box actually falls inside the viewport.
*/
private async visibleSectionFrame(): Promise<FrameLocator> {
const viewport = this.page.viewportSize() ?? { width: 1280, height: 720 };
for (let attempt = 0; attempt < 30; attempt += 1) {
const iframes = this.page.locator('.foliate-viewer iframe');
const frameCount = await iframes.count();
for (let i = 0; i < frameCount; i += 1) {
const paragraphs = iframes.nth(i).contentFrame().locator('p');
// A frame may be detaching mid-navigation; skip it if so.
const paragraphCount = await paragraphs.count().catch(() => 0);
for (let j = 0; j < Math.min(paragraphCount, 30); j += 1) {
const box = await paragraphs
.nth(j)
.boundingBox()
.catch(() => null);
// Accept a paragraph that intersects the viewport — off-screen
// prerendered sections sit fully outside it.
if (
box &&
box.width > 120 &&
box.height > 16 &&
box.x < viewport.width &&
box.x + box.width > 0 &&
box.y < viewport.height &&
box.y + box.height > 0
) {
return iframes.nth(i).contentFrame();
}
}
}
await this.page.waitForTimeout(400);
}
throw new Error('no visible book section found in the viewer');
}
/**
* Select a paragraph of book text and raise the annotation popup.
*
* Navigates to a chapter first so the page holds prose (the book opens on a
* cover page). The selection is made inside the section iframe and a
* `pointerup` is dispatched the exact pair of signals the reader's
* annotator listens for because synthetic mouse drags do not reliably
* produce a text selection through nested, paginated foliate iframes.
*/
async selectText(): Promise<void> {
await this.openTocChapter(3);
const frame = await this.visibleSectionFrame();
await frame.locator('body').evaluate(() => {
const paragraphs = Array.from(document.querySelectorAll('p'));
const target = paragraphs.find((p) => (p.textContent ?? '').trim().length > 60);
if (!target) {
throw new Error('no selectable paragraph in the visible section');
}
// Select a span within a text node — the reader's CFI generation
// expects text-node range endpoints, not element boundaries.
const walker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT);
let textNode = walker.nextNode();
while (textNode && (textNode.textContent ?? '').trim().length < 20) {
textNode = walker.nextNode();
}
if (!textNode) {
throw new Error('no text node found in the target paragraph');
}
const length = textNode.textContent?.length ?? 0;
const range = document.createRange();
range.setStart(textNode, 0);
range.setEnd(textNode, Math.min(length, 80));
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
const rect = range.getClientRects()[0] ?? range.getBoundingClientRect();
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: rect.left + Math.min(20, rect.width / 2),
clientY: rect.top + rect.height / 2,
bubbles: true,
pointerType: 'mouse',
}),
);
});
await this.annotationPopup.waitFor({ state: 'visible' });
}
/** A tool button inside the annotation popup, by its accessible name. */
popupTool(name: string | RegExp): Locator {
return this.annotationPopup.getByRole('button', { name });
}
async highlightSelection(): Promise<void> {
await this.popupTool('Highlight').click();
}
async selectHighlightColor(color: string): Promise<void> {
await this.page.locator(`[aria-label="Select ${color} color"]`).click();
}
/** Annotate the current selection with a note. */
async addNote(text: string): Promise<void> {
await this.popupTool('Annotate').click();
await this.noteEditor.waitFor({ state: 'visible' });
await this.noteEditor.getByRole('textbox').fill(text);
await this.notebook.getByRole('button', { name: 'Save' }).click();
}
/** Dismiss the annotation popup if it is open. */
async dismissPopup(): Promise<void> {
if (await this.annotationPopup.isVisible().catch(() => false)) {
await this.page.keyboard.press('Escape');
await this.annotationPopup.waitFor({ state: 'hidden' }).catch(() => {});
}
}
/**
* Open the sidebar's "Annotate" tab, which lists the book's annotations
* (assert against {@link annotationItems} afterwards).
*/
async openAnnotationsTab(): Promise<void> {
await this.dismissPopup();
await this.openSidebar();
await this.sidebar.locator('[aria-label="Annotate"]').click();
}
/** Delete the first annotation from the sidebar's "Annotate" tab. */
async deleteFirstAnnotation(): Promise<void> {
await this.openAnnotationsTab();
const item = this.annotationItems.first();
await item.hover();
await item.getByRole('button', { name: 'Delete' }).click();
}
}
@@ -0,0 +1,56 @@
import { expect, test } from '../fixtures/base';
test.describe('Annotation', () => {
test('shows the annotation popup when text is selected', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await expect(reader.annotationPopup).toBeVisible();
await expect(reader.popupTool('Highlight')).toBeVisible();
});
test('creates a highlight from the selected text', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
});
test('changes the highlight color', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.selectHighlightColor('green');
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
});
test('adds a note to the selected text', async ({ openBook }) => {
const reader = await openBook();
const noteText = 'A note added by the e2e suite';
await reader.selectText();
await reader.addNote(noteText);
await expect(reader.notebook.getByText(noteText)).toBeVisible();
});
test('deletes an annotation', async ({ openBook }) => {
const reader = await openBook();
await reader.selectText();
await reader.highlightSelection();
await reader.openAnnotationsTab();
await expect(reader.annotationItems).toHaveCount(1);
await reader.deleteFirstAnnotation();
await expect(reader.annotationItems).toHaveCount(0);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { expect, test } from '../fixtures/base';
import { SAMPLE_EPUB, SAMPLE_TXT } from '../fixtures/books';
import { LibraryPage } from '../pages/LibraryPage';
test.describe('Book import', () => {
test('imports a plain-text file and surfaces it in the bookshelf', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.emptyState).toBeVisible();
await library.importBook(SAMPLE_TXT);
await expect(library.bookshelf).toBeVisible();
await expect(library.bookCards()).toHaveCount(1);
});
test('imports an EPUB file and surfaces it in the bookshelf', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.emptyState).toBeVisible();
await library.importBook(SAMPLE_EPUB);
await expect(library.bookshelf).toBeVisible();
await expect(library.bookCards()).toHaveCount(1);
});
});
@@ -0,0 +1,26 @@
import { expect, test } from '../fixtures/base';
import { LibraryPage } from '../pages/LibraryPage';
test.describe('Library page', () => {
test('renders the library shell and an empty-library state', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.container).toBeVisible();
await expect(library.header).toBeVisible();
await expect(library.emptyState).toBeVisible();
});
test('search input accepts text and exposes a clear control', async ({ page }) => {
const library = new LibraryPage(page);
await library.goto();
await expect(library.searchInput).toBeVisible();
await library.searchInput.fill('a test query');
await expect(library.searchInput).toHaveValue('a test query');
await expect(library.clearSearchButton).toBeVisible();
await library.clearSearchButton.click();
await expect(library.searchInput).toHaveValue('');
});
});
@@ -0,0 +1,53 @@
import { expect, test } from '../fixtures/base';
test.describe('Reading', () => {
test('opens an EPUB and turns pages', async ({ openBook }) => {
const reader = await openBook();
await expect(reader.viewer).toBeVisible();
await reader.nextPage();
await reader.nextPage();
await reader.prevPage();
await expect(reader.viewer).toBeVisible();
});
test('navigates chapters via the table of contents', async ({ openBook }) => {
const reader = await openBook();
const startProgress = await reader.readingProgress();
await reader.openTocChapter(6);
// Reading progress updates asynchronously after the section loads.
await expect.poll(() => reader.readingProgress()).toBeGreaterThan(startProgress);
});
test('finds matches with in-book search', async ({ openBook }) => {
const reader = await openBook();
const resultCount = await reader.search('Alice');
expect(resultCount).toBeGreaterThan(0);
await reader.searchResults.first().click();
await expect(reader.viewer).toBeVisible();
});
test('increases the font size from the settings dialog', async ({ openBook }) => {
const reader = await openBook();
const { before, after } = await reader.increaseFontSize();
expect(Number(after)).toBeGreaterThan(Number(before));
});
test('adds and removes a bookmark', async ({ openBook }) => {
const reader = await openBook();
await reader.revealHeader();
await expect(reader.addBookmarkButton).toBeVisible();
await reader.addBookmarkButton.click();
await expect(reader.removeBookmarkButton).toBeVisible();
await reader.removeBookmarkButton.click();
await expect(reader.addBookmarkButton).toBeVisible();
});
});
@@ -0,0 +1,15 @@
# Webpack build output — re-generated by `pnpm build`. The packaged
# extension lives in `dist/` and is what you load unpacked in
# `chrome://extensions`.
dist/
# Per-package node_modules (the workspace hoists most of it to the root,
# but pnpm still drops a `.pnpm` symlink tree here).
node_modules/
# TypeScript incremental build artefacts.
*.tsbuildinfo
# Editor / OS noise.
.DS_Store
*.log
@@ -0,0 +1,233 @@
# Send to Readest — browser extension
One-click capture of the current web page into your Readest library as a
**self-contained EPUB**. Built for Chromium-based browsers (Chrome, Edge,
Arc, Brave). Manifest V3.
## What it captures
For every page the user clips, the extension produces a single `.epub`:
- Article body via [@mozilla/readability](https://github.com/mozilla/readability)
on the live, fully-rendered DOM — so paywalled / authenticated pages
capture the content the user can actually see.
- Lazy-loaded images surfaced by walking the DOM and resolving `srcset`,
`<picture>`/`<source>`, `data-src`, `data-original`, `data-srcset`,
`data-lazy`, and `data-actualsrc` in that order of preference (the same
set the server-side bundler in `src/services/send/conversion/assetBundler.ts`
handles).
- Image bytes fetched by the **service worker** under the user's existing
session (`credentials: 'include'`) and the extension's broad
`host_permissions: ["<all_urls>"]` — CORS doesn't apply, paywalled CDN
cookies do.
- A small bundled stylesheet (system fonts only — no remote fonts), so the
EPUB never makes a network request when opened offline.
- Inline images stored under `OEBPS/images/<sha256>.<ext>`, deduplicated by
hash so a hero shared between `<picture>` and `<img>` only ships once.
The EPUB is POSTed to **`POST /api/send/inbox/file`** with `kind=file`. The
server writes the bytes to R2 and inserts a `send_inbox` row; the next
Readest client to open drains the inbox and imports the EPUB as-is — no
further server-side conversion.
## Why client-side conversion
The previous version (`0.1.0`) sent only the page URL — the server then
tried to fetch and render it. That broke on:
- Paywalled / member-only content (server doesn't have the user's cookies).
- Bot-protected CDNs (Cloudflare, image hosts that gate on UA + Sec-Ch-Ua
headers + JS challenges).
- Lazy-loaded images that never materialize without a real scroll.
Building the EPUB on the capturing client side-steps all three. See
[D5 in the Send to Readest plan](../../docs/) and Part 4a / Part 8 of the
plan for the original design.
## Architecture
```
popup (popup.ts)
│ click "Send to Readest"
service worker (background/service-worker.ts)
│ chrome.scripting.executeScript({ files: ['content/capture.js'] })
content script (content/capture.ts) [runs in the page's tab]
├─ scrolls page once to materialize lazy images
├─ flattens open Shadow DOM (content/capture/shadow.ts)
├─ Readability extracts article body
├─ walks <img>/<picture>, rewrites src → placeholder tokens
├─ DOMPurify-sanitizes the article HTML
└─ returns { meta, articleHtml, images:[{placeholder,url}] }
service worker
├─ fetchAssets(images) — CORS-free, credentialed image downloads
├─ buildEpub — zip.js: mimetype, container, OPF, NCX, CSS, chapter, images
└─ uploadEpub — POST /api/send/inbox/file with EPUB bytes
server (src/pages/api/send/inbox/file.ts)
├─ putObject → R2 (inbox bucket, kind='file')
└─ insert send_inbox row
next Readest open → drainer imports the EPUB → book in library on all devices
```
A second always-on content script (`content/auth-bridge.ts`) runs only on
`web.readest.com` and copies the user's Supabase access token into the
extension's `chrome.storage.local` so the popup can authenticate to the
inbox endpoint without prompting for credentials. The extension never
stores a password or refresh token.
## Build
```bash
# From the extension directory:
pnpm install # one-time — the extension is a pnpm workspace package
pnpm build # produces dist/ ready to load unpacked
pnpm dev # watch mode while developing
```
The build is webpack-based:
- `src/background/service-worker.ts``dist/background/service-worker.js`
(bundles `@zip.js/zip.js`)
- `src/content/capture.ts``dist/content/capture.js`
(bundles `@mozilla/readability` + `dompurify`)
- `src/content/auth-bridge.ts``dist/content/auth-bridge.js`
- `src/popup/popup.ts``dist/popup/popup.js`
`manifest.json`, `popup.html`, and `icons/*` are copied verbatim into
`dist/` by `copy-webpack-plugin`.
## Load it for development
1. `pnpm build` (or `pnpm dev` for watch mode).
2. Open `chrome://extensions`, enable **Developer mode**.
3. **Load unpacked** → select this directory's `dist/` folder (not the
project root).
4. Visit <https://web.readest.com> once and sign in so the auth-bridge
content script captures the access token.
5. Click the extension's toolbar icon on any article page. The popup
reflects each phase: capturing → fetching images → building EPUB →
sending.
### Pointing the extension at a local Readest
The extension reads `chrome.storage.local.readestApiBase` if set, falling
back to `https://web.readest.com`. From the DevTools console of the
extension's background page:
```js
chrome.storage.local.set({ readestApiBase: 'http://localhost:3000' });
```
## Testing
Vitest exercises the extension's shell — upload (`X-Readest-*` headers, RFC
5987 encoding, error-code mapping, endpoint override), auth bridge
(`sb-*-auth-token` localStorage → `chrome.storage.local` sync, including
malformed JSON + storage-event rotation), `chrome.storage` auth helpers,
toolbar badge updates, the lazy-load scroll dance (incl.
`prefers-reduced-motion`), and the popup UI rendering for every progress
phase. From the `apps/readest-app` workspace root:
```bash
pnpm test:extension # 47 shell tests, ~1 s
pnpm build-browser-ext # production webpack build (catches alias / stub regressions)
pnpm test # full suite — also runs the extension tests via vitest's default glob
```
The shared EPUB pipeline (`convertPageToEpub`) and the server's
`/api/send/inbox/file` endpoint have their own tests under
`apps/readest-app/src/__tests__/services/`. The unification regression
specifically lives in `send-convert-page-unified.test.ts`.
**CI coverage:** the GitHub Actions `test_web_app` job runs
`pnpm test:pr:web`, which invokes the full vitest suite (extension shell
tests included), the browser-test suite, and the extension's production
webpack build. A webpack-config or shared-pipeline regression fails the
job on its own line.
## Internationalisation
The extension uses **key-as-content** i18n (matches the readest-app's
`stubTranslation as _` convention): the English source string IS the
lookup key. Import as `_` at every call site to mirror the main repo:
```ts
import { translate as _ } from '../lib/i18n';
_('Send to Readest');
_('Sent — {count} images could not be fetched.', { count });
```
Two parallel translation surfaces:
| Folder | Scope | When it's read |
|---|---|---|
| `src/locales/<lang>.json` | 29 runtime UI strings — popup, errors, status, badges. `{ "<english source>": "<translation>" }`. | At runtime by the `_(...)` helper. Falls through to the English key when an entry is missing or set to the `__STRING_NOT_TRANSLATED__` sentinel. |
| `_locales/<lang>/messages.json` | Three manifest fields — `app_name`, `app_description`, `action_title` — referenced as `__MSG_*__` in `manifest.json`. | At install time + by the Chrome Web Store listing. Chrome falls back to `default_locale` (en) automatically, so a locale file is only needed when you want to override the toolbar tooltip / store copy. |
The full set of supported locales lives in
`apps/readest-app/i18n-langs.json`. The extension's extractor reads the
same file, so a locale added there ships in the extension automatically
on the next `pnpm i18n:extract` run.
### Extracting strings
After adding `_('...')` calls or `data-i18n="..."` attrs:
```bash
pnpm i18n:extract # populates every src/locales/*.json with new keys
pnpm i18n:check # exits non-zero if any bundle has untranslated entries
```
The extractor:
1. Reads the canonical locale list from
`apps/readest-app/i18n-langs.json` and ensures a stub
`src/locales/<lang>.json` exists for every entry (creates an empty
`{}` for any missing locale).
2. Scans every `.ts`/`.tsx` (skipping `*.test.ts`) and every `.html` file
under the extension.
3. Pulls source strings from `_('…')` calls AND `data-i18n` /
`data-i18n-title` HTML attributes.
4. For every non-`en` locale: adds missing entries with
`__STRING_NOT_TRANSLATED__` (same sentinel readest-app uses), keeps
existing translations, sorts keys for deterministic diffs.
5. Regenerates `src/locales/index.ts` with one static import per locale
bundle — the runtime helper reads its `bundles` map.
6. Mirrors the locale list into `_locales/<lang>/messages.json` stubs
(Chrome's native i18n surface for manifest fields). Existing
translations are never overwritten — only missing locales get
created as `__STRING_NOT_TRANSLATED__` stubs.
7. Logs orphan entries (in the bundle but no longer in code) so a
translator can decide whether to drop them.
The runtime helper filters sentinel entries at load time, so a
partially-translated bundle gracefully falls back to English per-key
instead of leaking placeholders into the UI.
### Adding a locale
To add a locale the extension ships in lockstep with the main app:
1. Add the code to `apps/readest-app/i18n-langs.json`.
2. `pnpm i18n:extract` from the extension dir — creates
`src/locales/<code>.json` populated with `__STRING_NOT_TRANSLATED__`
placeholders, regenerates `src/locales/index.ts`.
3. Hand-translate each value.
4. *(Optional)* Drop `_locales/<code>/messages.json` mirroring the en
file if you want the toolbar tooltip / Chrome Web Store listing
translated too. Chrome falls back to `en` when this is missing.
5. Rebuild — webpack picks up the new JSON via the regenerated index.
## Before publishing to the Chrome Web Store
- Replace the icon set (currently a 1:1 downscale of the Readest app icon)
with extension-specific artwork.
- Add a screenshot bundle and a privacy disclosure: the extension reads the
page DOM and fetches its image references; nothing is sent off-device
except to the Readest inbox.
- Submit through the Chrome Web Store Developer Dashboard (review required).
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "إرسال إلى Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "احفظ صفحات الويب في مكتبة Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "إرسال إلى Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Readest-এ পাঠান",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "ওয়েব পৃষ্ঠাগুলিকে আপনার Readest লাইব্রেরিতে সংরক্ষণ করুন",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Readest-এ পাঠান",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Readest ལ་གཏོང་།",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "དྲ་ཤོག་Readest་དཔེ་མཛོད་དུ་ཉར་ཚགས་བྱེད",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Readest ལ་གཏོང་།",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "An Readest senden",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Speichere Webseiten in deiner Readest-Bibliothek",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "An Readest senden",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Αποστολή στο Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Αποθηκεύστε ιστοσελίδες στη βιβλιοθήκη Readest σας",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Αποστολή στο Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Send to Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Save web pages to your Readest library",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Send to Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Enviar a Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Guarda páginas web en tu biblioteca de Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Enviar a Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "ارسال به Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "صفحات وب را در کتابخانه Readest خود ذخیره کنید",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "ارسال به Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Envoyer vers Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Enregistrez des pages web dans votre bibliothèque Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Envoyer vers Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "שלח ל-Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "שמור דפי אינטרנט בספרייה שלך ב-Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "שלח ל-Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Readest पर भेजें",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "वेब पृष्ठों को आपकी Readest लाइब्रेरी में सहेजें",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Readest पर भेजें",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Küldés a Readestbe",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Mentsd a weboldalakat a Readest-könyvtáradba",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Küldés a Readestbe",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Kirim ke Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Simpan halaman web di perpustakaan Readest Anda",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Kirim ke Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Invia a Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Salva pagine web nella tua libreria Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Invia a Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Readest に送信",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "ウェブページを Readest ライブラリに保存します",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Readest に送信",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Readest로 보내기",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "웹페이지를 Readest 라이브러리에 저장합니다",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Readest로 보내기",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Hantar ke Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Simpan halaman web dalam perpustakaan Readest anda",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Hantar ke Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Naar Readest sturen",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Sla webpaginas op in je Readest-bibliotheek",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Naar Readest sturen",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Wyślij do Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Zapisuj strony internetowe w bibliotece Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Wyślij do Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Enviar para Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Guarde páginas web na sua biblioteca Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Enviar para Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Enviar para o Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Salve páginas web em sua biblioteca Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Enviar para o Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Trimite la Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Salvează pagini web în biblioteca ta Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Trimite la Readest",
"description": "Tooltip on the toolbar action button."
}
}
@@ -0,0 +1,14 @@
{
"app_name": {
"message": "Отправить в Readest",
"description": "Extension name shown in the browser toolbar and the Chrome Web Store listing."
},
"app_description": {
"message": "Сохраняйте веб-страницы в библиотеке Readest",
"description": "Short description shown in the Chrome Web Store listing."
},
"action_title": {
"message": "Отправить в Readest",
"description": "Tooltip on the toolbar action button."
}
}

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