Move the informational Data pack hints ("Open a book…" / "No data available…")
from the inline trailing slot into the row description so they wrap under the
label instead of stretching the row wide on mobile. Drop the size from the
Download button label and surface it as the row description (matching the
"Downloaded · size" state). Add a "CEFR level" description under the Level row.
Includes translations for the three new i18n keys across all locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): count only uploaded, non-deleted books in synced toast
The "N book(s) synced" toast shown on pull-to-refresh and the
last-synced menu counted every non-deleted book record a pull
returned, including books indexed in the cloud as metadata only
whose file blob was never uploaded. Those books are never added to
the library (updateLibrary requires uploadedAt && !deletedAt), so
the toast over-reported.
Extract the count into a pure countSyncedRecords(type, records)
helper that excludes deleted records and, for books only, requires
uploaded_at — matching what actually lands in the library.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(memory): record agent notes for recent fixes
Persisted project-memory docs accumulated alongside recently merged
work (biometric app-lock, download-file scope regression, inline-block
column overflow, iOS instant-dict double popup, RSVP RTL words, web
security advisories) plus MEMORY.md/share-feature index updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Series sort comparator compared only seriesIndex, ignoring the series
name. When used as the within-group order for "group by Author → sort by
Series", this ranked every book #1 across all series as a block, then every
#2, etc., scattering each series instead of keeping it consecutive.
Compare series name first, then index, so all books of one series appear
together in series order before the next series begins.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#4639 added a strict `app.fs_scope().is_allowed()` check to download_file/
upload_file. On Android that returns false for the app's own storage, so every
download into the app dir (book covers, dictionaries, books, gloss packs, OPDS
books in the cache dir) failed with "permission denied: path not in filesystem
scope".
Root cause: download_file/upload_file are plain app commands using raw tokio::fs,
so Tauri does not scope file_path. The capability scope patterns that cover the
app's storage ($APPDATA/Readest/**, $APPCACHE/**, **/Readest/**/*) are
command-scoped and absent from the global fs_scope() FsExt exposes (it is
initialized FsScope::default() and only ever gains runtime dialog/persisted-scope
grants), so is_allowed() returns false for the app's own files.
Interim fix mirroring dir_scanner::read_dir: keep rejecting relative and `..`
paths, then accept the path if the fs scope allows it (persisted dialog grants
for custom/external roots) OR it lives inside the app's own storage — matched by
the `Readest` data folder or the app's bundle identifier (app.config().
identifier), which the Android sandbox (/data/user/0/<id>/…, cache dir included)
and the desktop identifier dirs always carry. The `..` rejection keeps the
GHSA-55vr-pvq5-6fmg hardening: foreign targets like ~/.ssh/id_rsa carry neither
segment and stay blocked.
Follow-up (tracked separately): replace the substring fallback with a
BaseDirectory + relative path resolved via app.path(), so targets are in-scope by
construction with no string markers.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FB2 stores series info as `<sequence name="…" number="…"/>` inside
`<title-info>`, but the foliate-js FB2 parser never read it, so
`belongsTo.series` was always empty and the series name/index never
surfaced in the library or book details. Refresh-metadata and
re-import didn't help since they share the same parser path.
Bumps the foliate-js submodule to pull in the `<sequence>` parsing
(readest/foliate-js#28) and adds a regression test + fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The RSVP word window split each word into before/orp/after spans at the
ORP index and laid them out left-to-right. Slicing an Arabic/Hebrew word
by character index breaks letter shaping (letters stop connecting, some
slices render as notdef boxes) and the LTR layout reverses the visual
order, so e.g. علم showed as disconnected, out-of-order letters.
Detect RTL text and render the word as a single centered span — reusing
the existing CJK Highlight Word path — with dir="rtl" so the browser
shapes and orders it correctly, matching the context panel. ORP anchoring
is meaningless for unsplittable shaped scripts, so RTL always renders
whole; no new toggle.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A highlight and its note are now a single BookNote. Adding a note attaches
it to the highlight at that CFI (or creates one with the current global
style) instead of creating a second record, and a unified record renders as
both a highlight overlay and a note bubble.
- onDrawAnnotation chooses the draw kind from the overlay value prefix
(cfi -> highlight, NOTE_PREFIX -> bubble) instead of annotation.note, so a
record with both a style and a note draws both. Fixes notes synced from
KOReader losing their highlight (#4511).
- handleSaveNote updates the existing annotation at the CFI rather than
pushing a new record (#3870); re-styling preserves the note.
- unifyAnnotations migration (book config schema v1 -> v2, run in
deserializeConfig) collapses existing split highlight+note records into one
survivor and tombstones the redundant record (deletedAt) so the merge syncs
to the cloud and KOReader.
- Sidebar: a note's quoted highlight text uses the theme foreground so it
stays legible on the highlight background.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On iOS a single long-press emits several selectionchange events, so the
instant quick action fired the system dictionary 2-3 times, stacking
UIReferenceLibraryViewController sheets. Add a once-per-gesture latch to
deferredAction (re-armed by beginGesture on touchstart/pointerdown) so the
action runs at most once per gesture, mirroring the Android
defer-to-touchend coalescing.
Also fix two related cases:
- Tapping outside to deselect after dismissing the dictionary occasionally
re-opened it (~1/3): the deselect tap re-armed the latch and a racy
lingering selectionchange re-fired. Gate the instant action on a
long-press hold (isLongPressHold, 300ms, touch only) so a quick tap can't
trigger it.
- A Word Lens gloss tap ignored the system-dictionary setting (always
opened the in-app popup); route it through handleDictionary so it honors
the system dictionary like the toolbar and instant-quick-action paths.
Verified on Android (Xiaomi) that the instant dictionary still fires once
per long-press and re-arms for the next gesture; iOS double-popup and
tap-to-deselect re-fire confirmed fixed.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The next-section accessibility skip link is injected nested inside each
section's last content element (findSectionEndHost in a11y.ts, added for
#4126). The paragraph-layout rule in getParagraphLayoutStyles() targets
`div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the
enclosing paragraph fail the :has() test and silently lose its line-spacing,
word/letter-spacing, text-indent, and hyphenation overrides — but only for the
last paragraph of every section, and only in <div>-based EPUBs (common in
Chinese-source books). <p>-based books were unaffected because the bare `p`
clause matches regardless of children.
Create the next-section skip link as a <span> instead. <span> is in the
selector's allow-list, so the enclosing paragraph keeps matching. The link is
still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and
NVDA focus behavior are unchanged.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27)
Some EPUBs wrap a large chunk of chapter content in a div the stylesheet
declares as `display: inline-block`. Atomic inline-level boxes can't fragment
across CSS columns, so in paginated mode the tall box overflows the page
vertically and every column past the first is clipped — the chapter jumps
straight to its "Reference materials", silently skipping a large middle
section, while the counter reads "1 page left in chapter".
Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes
over-tall atomic-inline boxes to their fragmentable block equivalents in
column mode) and adds a browser test + repro EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump foliate-js submodule to merged main (#27)
Re-point packages/foliate-js from the PR-branch commit to the squash-merged
main SHA now that readest/foliate-js#27 has landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some EPUBs ship an OPF that isn't well-formed XML — a bare, unescaped
`&` in a hand-built manifest id, e.g.:
<item id="Chapter_1213_Search_&_Rescue_153" .../>
A strict XML parser rejects it (`EntityRef: expecting ';'`) and the book
fails to import on every platform: the web/desktop/Android reader-open
path (foliate `EPUB.#loadXML`) and the Android/desktop native-import
bridge (`parseEpubMetadataFromXML`, which parsed unsanitized).
Bump foliate-js to escape any `&` that doesn't begin a valid character
or entity reference, applied at both parse sites (readest/foliate-js#26).
Valid and numeric references are preserved.
Verified end-to-end on the real "Shadow Slave - Vol. 6" EPUB: imports in
the web app (Chrome) and on a physical Xiaomi device via the native path,
both of which previously failed. New unit test covers both
`parseEpubMetadataFromXML` cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`download_file` and `upload_file` passed a webview-supplied `file_path`
straight to `File::create`/`File::open` with no validation, so any JS in the
privileged Tauri origin could write or read arbitrary local paths (e.g.
~/.ssh/id_rsa, shell rc files, autostart entries). (GHSA-55vr-pvq5-6fmg)
Validate the path before any file open/create: reject relative paths and `..`
traversal, then require it to be inside the app's filesystem scope
(`fs_scope().is_allowed`), the same mechanism `dir_scanner::read_dir` uses.
Legitimate destinations stay covered — the static capability globs ($APPDATA
/Readest, $APPCACHE, $TEMP) plus persisted dialog grants for custom roots and
external library folders.
AppHandle is injected by Tauri, so the JS invoke surface is unchanged. Adds a
unit test for the traversal/relative-path rejection.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Server-side hardening for three reported web advisories:
- OPDS proxy (/api/opds/proxy): add http(s) scheme allowlist, internal/loopback/
link-local host blocklist, and manual per-hop redirect re-validation so a
public URL can't redirect into an internal address. Move isBlockedHost into
the shared src/utils/network.ts as the canonical blocklist and reimplement
isLanAddress to delegate to it (also tightens the /api/kosync LAN check);
fetch-url.ts re-exports it. (GHSA-c7mm-g2j2-98cx, GHSA-5g3f-mq2c-j65v)
- Storage upload (/api/storage/upload): validate the client-supplied fileName
with a new isSafeObjectKeyName helper before building the object key, so a
name can't escape the caller's own prefix. (GHSA-mfmj-2frf-vhgw)
- Stripe (/api/stripe/check): bind the entitlement to the session owner —
reject a Checkout Session whose metadata.userId differs from the authenticated
caller. (GHSA-pv88-3727-j7v8)
Unit tests added for each path; full suite + lint green. The Tauri-native
advisory (GHSA-55vr-pvq5-6fmg) is handled in a separate change.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three issues found while debugging shared-book links:
- /s cover was a broken <img>: the page runs under COEP: require-corp (for
Turso SharedArrayBuffer), and the cover redirects to a cross-origin R2
presigned URL that can't carry a Cross-Origin-Resource-Policy header, so the
browser blocked it. R2 already has CORS, but that's a different header — a
plain no-cors <img> needs CORP, which presigned URLs can't set. Serve /s with
COEP: credentialless, which keeps the page cross-origin isolated (the Turso
replica still boots there) while allowing the image. Scoped to /s; every other
route keeps require-corp.
- Android share links (https://web.readest.com/s/{token}) were run through the
article clipper: useClipUrlIngress excluded annotation links but not share
links, so they fell through to clip_url/readability. Skip parseShareDeepLink
URLs — useOpenShareLink owns that path.
- In-app book import failed with "Origin null is not allowed": the importer
fetched /share/{token}/download with the renderer's fetch, and on the app
(tauri.localhost -> web -> R2) the second cross-origin redirect nulls the
request Origin, which R2's CORS rejects. Use the native HTTP client
(tauriFetch) on the app — it follows the redirect and ignores CORS, needs no
server change, and works against the deployed server. Web is unaffected: its
fetch's redirect to R2 is the first cross-origin hop, so the Origin is
preserved and R2 allows it.
Adds unit tests (middleware COEP per route, clipper skips share links, download
route 302, importer uses native HTTP on app and the renderer fetch on web).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(wordlens): rename ww-gloss CSS class to wl-gloss
Completes the Word Wise → Word Lens rename (#4633) for the gloss ruby class —
the 'ww' shorthand was missed. Renamed consistently across the apply site
(GLOSS_CLASS), the CSS rules in style.ts, the tap hit-test in
iframeEventHandlers, and the browser tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(wordlens): trim hints to first sense + suppress known derivations
Runtime, best-effort gloss-quality pass over the shipped en-zh pack (no
regeneration):
- cleanGloss: strip leading POS tags (now incl. 6-letter `interj.`) and keep
only the first sense, so hints stay short — "Ahem" shows 呃哼, not
"interj. 呃哼"; multi-sense entries collapse to their first sense.
- Derivational reduction (English source only): a would-be-glossed word inherits
a known base form's lower rank when the base exists in the pack AND their
glosses share meaning, so lazily/shyly/sorrowful/downwards/inwards stop being
hinted once lazy/shy/sorrow/… are known. Drifted forms keep their own rank
because their gloss doesn't overlap the base (hardly≠hard, lately≠late).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(wordlens): note hint-quality layer + wl-gloss in agent memory
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
"Word Wise" is a Kindle trademark, so rename the inline-gloss feature to
"Word Lens" throughout the product.
- User-facing strings → "Word Lens" across all 34 locales; brand translated
for Chinese (zh-CN 单词透镜, zh-TW 單詞透鏡) and German (Word-Lens-Daten).
- Code identifiers: WordWise→WordLens, wordWise→wordLens, WORD_WISE→WORD_LENS.
- Files/dirs: src/services/wordwise→wordlens, WordWisePanel→WordLensPanel,
wordwise{Ruby,Section}.ts, build/sync scripts, test dirs/fixtures,
data/wordwise→data/wordlens.
- Storage paths: CDN base, R2 key, on-device cache dir, WORDLENS_R2_BUCKET env,
pnpm wordlens:{manifest,sync}. manifest.json is path-agnostic so its
sha256/bytes stay valid (verified).
- biome.json: point the formatter-ignore at data/wordlens so the generated
one-line gloss packs aren't pretty-printed on commit.
Migration notes:
- Re-run `pnpm wordlens:sync` to upload packs to cdn.readest.com/wordlens/.
- Persisted view-settings keys renamed (wordWiseEnabled/Level/HintLang and
wordWiseAutoDownload) — saved values reset to defaults once on upgrade.
- Cached packs under the old Data/wordwise/ orphan (harmless re-download).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deleting a highlight or bookmark in the koplugin never reached the
server. SyncAnnotations:push builds its payload from getAnnotations,
which walks only the live ui.annotation.annotations list, so a deleted
note can never appear in a push — the server kept the row and the next
pull resurrected it. The pull direction (server deletions → koplugin)
was already handled by removeDeletedAnnotations (#4119); this is the
missing push direction.
Capture a tombstone at deletion time instead. onAnnotationsModified
detects a removal (negative index_modified, with the deleted item at
items[1]) and records a deletedAt-stamped descriptor in the per-book
sidecar (readest_sync.deleted_notes). push folds those tombstones into
the payload and clears them only once the server accepts them, so a
failed push retries. Extracted buildNoteDescriptor so the deletion path
derives a note's id identically to the push walk.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fulltext search showed only the matched word with no surrounding context
when the match fell inside inline-styled text (e.g. <i>/<em>). The root
cause and fix live in the foliate-js submodule's makeExcerpt
(readest/foliate-js#25); bump the submodule pointer to pick it up and add
a regression test covering both the simpleSearch and segmenterSearch paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inside a Series/Author library folder, the back arrow was a no-op after a
cold start. `GroupHeader.handleBack` deleted the `group` query param, leaving
an empty search string; `router.replace('/library')` with an empty search
silently no-ops under the Next.js 16.2 static export (every non-web build).
This is the same root cause as #3782, which was fixed for the breadcrumb
"All" button in #3832 — but the series/author back button never got the
workaround.
It only reproduces after a cold start, when `groupBy` comes from settings
(not the URL) and sort/order/view are at defaults, so `group` is the only
query param; that is why it could not be reproduced within a session.
Fix: set `group=''` instead of deleting it (mirroring
`handleLibraryNavigation`). The resulting `/library?group=` commits, and the
existing cleanup effect in page.tsx strips the trailing empty `group=`.
Verified on-device (Android, WebView 148, static export): tapping back inside
an author folder now returns to the main list.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backports the Android ANR/stability fixes from the julianshen fork,
adapted to upstream — notably preserving the cold-start shared-intent
replay queue and the #4559 dictionary-dispatch logic, neither of which
the fork kept.
- NativeBridgePlugin: run blocking @Command I/O (copy_uri_to_path,
install_package, get_sys_fonts_list, and show_lookup_popover's
queryIntentActivities) on Dispatchers.IO via a Main-dispatched
pluginScope; startActivity hops back to Main; resolves are
isActive-guarded; onDestroy cancels the scope and clears the static
instance; the system-font scan is cached (@Volatile). The cold-start
shared-intent queue (emitOrQueue / registerListener) is left intact.
- MediaPlaybackService: unmarshal the artwork Bitmap off the main thread
(serviceScope + Dispatchers.Default), isActive-guarded; cancel the
scope in onDestroy.
- ClipUrlController: hold the Activity via WeakReference and check
isFinishing/isDestroyed before presenting, to avoid leaking the
Activity/WebView during the up-to-30s clip window.
- MainActivity (#3297): on Android 14+ the window can gain focus before
the WebView paints its first frame, leaving a blank screen. Force one
repaint when both the window has focus and the WebView exists
(whichever happens last).
Kotlin-only; not exercised by the JS/Rust test suites. Verified via
ktlint parse + a release `tauri android build` and on-device smoke test
(Xiaomi). The touch-event throttle and intent-handling rewrite from the
fork are intentionally NOT backported (they dropped touchmove forwarding
and the cold-start queue).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening a book via Android "Open with" on a cold start could clear the
entire library. `openTransient` built an ephemeral entry on top of the
not-yet-loaded (empty) store; the library page's `length > 0` cached-skip
then skipped loading the real library from disk; and a later
`saveLibraryBooks` persisted the empty/partial set — overwriting both
library.json and its .bak. Introduced by #4407 (transient "Open with"),
made reliably reproducible by #4527 (reliable cold-start delivery) — so
released v0.11.4, which lacks #4527, does not reproduce it.
Two layers of defense:
- saveLibraryBooks now merges with the on-disk library (union by hash,
incoming wins), so a routine save is monotonic: it can add or modify
rows (including `deletedAt` tombstones) but can never drop a book.
Deliberate, authoritative rewrites (restore, tombstone GC, account
reset) opt in via the new `{ replace: true }`. This layer alone makes
the wipe impossible.
- openTransient loads the real library from disk before importing a
transient book — also fixing the cold-start hash-match miss that
re-imported already-imported books — and the library page's load-skip
now gates on the store's `libraryLoaded` flag instead of `length > 0`.
Tests cover the merge floor (no-drop / no-wipe-on-empty / tombstone
preserved / incoming-wins) and both `{ replace: true }` paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolves#4615. Re-importing updated serials left the app-generated
Books/<hash>/ folder (config.json reading progress/notes, nav.json, cover)
on disk after a normal delete, forcing a manual cleanup. "Purge Data" now
does a Cloud & Device delete AND wipes the whole directory in one action.
The book detail action row is redesigned to Edit · Download · Upload ·
Delete · More (hamburger):
- Goodreads, Share, and Export move into the hamburger "More" menu.
- Share is enabled only when signed in and the local file exists; Export
is enabled when the local file exists (kept on every platform since the
bottom-bar Send is mobile/macOS-only).
- Purge Data is the red entry in the Delete menu, behind a strong confirm.
Implementation:
- DeleteAction gains 'purge'; cloudService.deleteBook('purge') removes the
in-place source file and removeDir's the whole Books/<hash>/ folder,
clearing downloadedAt and leaving the tombstone + queued cloud delete to
the page (mirrors 'both'/'local').
- The library page wires handleBookDelete('purge'); BookDetailModal adds
the purge confirm config + share/export handlers and gates Share on auth.
Tests: cloud-service purge cases, BookDetailView More-menu + Purge tests.
i18n: 9 new keys translated across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Docker production-stage opts into Next.js `output: 'standalone'` via a
BUILD_STANDALONE env flag, so it ships only the traced runtime (server.js +
hoisted node_modules + static/public) and runs `node server.js` instead of
pnpm over the full source tree. The flag — and `outputFileTracingRoot`,
which traces from the monorepo root so workspace packages are included — is
set only in the Dockerfile build stage. Every other path keeps its original
output: Tauri `export`, local `build-web`, dev, and the Cloudflare/OpenNext
deploy (which forces standalone itself via NEXT_PRIVATE_STANDALONE).
Disable the experimental `turbopackFileSystemCacheForBuild`: a build
interrupted mid-compile leaves a partial cache that the next build
mishandles, fanning out workers until it exhausts host RAM. Remove the
pull-request CI step that cached `.next/cache` for it, now unused.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve a batch of transitive Dependabot alerts on the web lockfile via
pnpm-workspace overrides.
Bumped existing overrides:
- vite >=7.3.5 (#244 high, #245 med; GHSA path within 7.3.x) <- was 7.3.2
- dompurify >=3.4.9 (#249-#255; clears #252 <=3.4.6 by leaving the range)
- protobufjs >=7.6.3 <8 (#233, #247, #248); bounded <8 to stay on the
patched 7.x line (a bare floor let pnpm jump to the 8.x major)
- ws >=8.21.0 (#241 high) <- was pinned 8.20.1
Added overrides:
- form-data >=4.0.6 (#246 high)
- js-yaml >=4.2.0 (#243 med)
- '@babel/core' >=7.29.6 (#242 low)
- '@opentelemetry/core' >=2.8.0 (#256 med)
Not auto-fixable here: #236 @ai-sdk/provider-utils (<=3.0.97, no recorded
fix; the installed 3.0.25 is pinned via a local patchedDependencies patch).
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resolve transitive Dependabot alerts on the web lockfile.
esbuild >= 0.28.1 (GHSA-gv7w-rqvm-qjhr #239, GHSA-g7r4-m6w7-qqqr #238):
Deno-module RCE via NPM_CONFIG_REGISTRY and Windows dev-server arbitrary
file read. Forced via a pnpm-workspace override (esbuild is a regular dep
of vite, so the override applies cleanly); bounded to <0.29 to stay on the
verified line. vite 7.3.3 declares ^0.27.0, but the 0.28 JS API is
unchanged for vite's usage -- verified by the full test run and web build.
@vitest/browser >= 4.1.8 (GHSA-g8mr-85jm-7xhm #240): Browser Mode CDP
bridge bypasses allowWrite/allowExec, enabling config overwrite -> RCE.
Bumped the vitest devDep family (vitest, @vitest/browser-*, coverage-v8)
from ^4.0.18 to ^4.1.8; resolves to 4.1.9.
Verified: pnpm test (5685 passed), pnpm lint, pnpm build-web (exit 0).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(library): add Clear Pending action to transfer queue
Adds a "Clear Pending" button to the transfer queue panel that removes
only pending (including retry-pending) transfers, leaving in-progress,
completed, failed, and cancelled items intact. Wired through the store,
manager (with queue persistence), and useTransferQueue hook.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n: translate Clear Pending and reading-statistics strings across locales
Adds translations for "Clear Pending" (transfer queue) and the two
reading-statistics sync-category strings ("Reading statistics" and its
description) across all 33 locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardcover sync previously only ran when the user opened the reader menu
and tapped "Push Progress" / "Push Notes". Add an opt-in Auto Sync toggle
(default OFF) to the Hardcover settings so progress and notes are pushed
automatically while reading.
- useHardcoverSync: silent debounced (10s) auto-push of progress on page
turns and of notes on annotation/excerpt changes, gated on
enabled && autoSync === true; pending pushes flush on the existing
sync-book-progress close event and cancel on unmount. Manual menu
actions are unchanged (still loud).
- HardcoverSettings.autoSync flag (default false); existing connected
users stay manual until they opt in.
- HardcoverForm: new "Auto Sync" toggle row.
Also backfills two untranslated strings surfaced by i18n:extract from the
reading-stats feature (#4606) across all locales, plus the new
"Auto Sync" key.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.
- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
to `gap-1` so the extra line fits the fixed-height row without clipping.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An OPDS entry with full metadata and a cover image but no acquisition
link — e.g. a Calibre book whose file was removed but kept for tracking
borrowed/loaned titles — was classified by foliate-js as a navigation
item whose href fell back to the cover image link. Tapping it loaded the
image, which is neither XML nor JSON, so the OPDS browser crashed with a
JSON parse error.
- Bump foliate-js to include the getFeed fix that classifies such
metadata-only entries as publications instead of navigation.
- PublicationView: show "No downloadable format available" when an entry
has no acquisition or stream links.
- loadOPDS: defense-in-depth — surface a clear message instead of a raw
JSON.parse SyntaxError when a response is neither XML nor JSON.
- Add tests covering the Calibre no-format entry and a regression guard
that a true navigation entry still classifies as navigation; add the
two new UI strings across all locales.
Closes#4599
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supersedes #3156. Adds a reading-statistics system whose canonical data model
is KOReader's own (book + page_stat_data), so stats round-trip losslessly
between Readest and KOReader.
- Storage: a cross-platform Turso statistics.db in KOReader's exact schema
(web/Workers, desktop, iOS, Android) — replacing #3156's Node-only
better-sqlite3 + statistics.json.
- Tracking: per-page reading events (time-on-page, idle-capped) flushed on
page-change/idle/hide/close — the KOReader model — not session aggregates.
- Sync: legacy /api/sync extended with a stats type backed by self-contained
Supabase tables (stat_books, stat_pages); union/longer-duration-wins merge
keyed on book_hash. apps/readest.koplugin syncs through the same endpoint.
- Scale & robustness: per-tab singleton connection (avoids OPFS lock
conflicts) + explicit WAL checkpoint; transactional bulk apply; chunked
resumable push; client-driven paged pull with trailing-ms completion;
paginated/scoped server merge.
Verified: 5668 unit tests, 155 koplugin busted tests, biome+tsgo + luacheck
all green; web OPFS DB verified live.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The license, release, and last-commit badges all query GitHub's API
through shields.io's shared instance, which intermittently fails with
"Unable to select next GitHub token from pool" when shields.io's own
token pool is rate-limited. A README author can't supply a token to the
hosted badges.
- License is fixed at AGPL-3.0, so use a static badge that makes no
GitHub API call and can never hit the token-pool error.
- Switch the release badge from the deprecated `github/release`
endpoint (which 301-redirects) to `github/v/release`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the Sponsors subsection and its lone TestMu AI logo from the
Support section.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): open image gallery & table zoom on single tap
In reflowable EPUBs, a single tap on an image or table now opens the same
viewer a long-press opens, so the image gallery / table zoom is reachable by
both gestures. Fixed-layout books (PDF/comics/manga) keep tap-to-turn, and
long-press is unchanged everywhere.
Reuses the existing iframe-long-press -> handleImagePress/handleTablePress
flow via a new shared detectMediaTarget() helper (also adopted by the
long-press path so the two entry points can't drift). handleClick now takes
an isFixedLayout flag; the tap branch sits after the link/footnote/drag/
long-hold/Word-Wise guards so linked images still follow links and a
long-hold or double-tap won't double-trigger.
Context: #4584 (single taps stop registering after picture zoom on some
WebView builds) - this adds a second, independent way into the viewer rather
than fixing that root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(reader): rename iframe-long-press message to iframe-open-media
The message is now posted for both a long-press (any book) and a single tap on
an image/table (reflowable books), so the long-press-specific name was
misleading. Rename the message type to `iframe-open-media` and the consumer
hook `useLongPressEvent` -> `useOpenMediaEvent`. The long-press detector
(`addLongPressListeners`/`handleLongPress`) keeps its name since it still
detects a long-press specifically.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Re-importing a folder via the in-place option used to reopen, parse,
and partial-MD5 every file before its byHash entry could short-circuit
the import. Worse, the byHash short-circuit treated every hit as "user
dropped a fresh file matching a known book", so it refreshed
createdAt/updatedAt/downloadedAt and cleared filePath/deletedAt. For a
user re-scanning the same external folder, that quietly rewrote sort
order and wiped soft-delete state. And once the import returned, the
ingest pipeline still ran group / tag / upload work — including a
path-derived empty groupId that silently clobbered manual
GroupingModal assignments on every re-scan.
This change adds an explicit byFilePath fast path at the top of
`ingestFile` so a re-scan returns the existing library entry verbatim,
before any I/O AND before any downstream side effect:
byFilePath hit -> the same on-disk source is being re-scanned in
place; the right answer is no-op. Don't open the
file, don't touch any timestamps, don't re-cover,
don't run the group / tag / upload steps.
byHash hit -> a different source path resolves to a known book
(e.g. the user dropped a copy from elsewhere, or
a soft-deleted book is being revived); the
existing "refresh timestamps + clear deletedAt"
behavior in importBook is correct here and is
left intact.
Implementation:
- BookLookupIndex carries byHash / byMetaKey / byFilePath, with
byFilePath built only from non-deleted books that have an absolute
filePath. normalizeFilePathForIndex is the shared key function so
shouldImportInPlace and the index agree on case-insensitive
filesystems (macOS / iOS / Windows). osPlatform threads through
buildBookLookupIndex and BaseAppService.importBook so the renderer
and the importer compute the same key for the same file.
- ingestService.ingestFile gains a byFilePath fast path before its
importBook call: when `inPlace` was decided positive, the source
is a real on-disk path (not a PSE stream / URL / content URI),
and lookupIndex.byFilePath has a hit, return the existing Book
directly. Returning here — rather than inside importBook — is
deliberate: it skips the downstream group / tag / upload steps so
a re-scan can never silently overwrite a manual GroupingModal
assignment via a path-derived empty groupId.
- ingest-service.test.ts covers both halves: an in-place re-import
short-circuits importBook entirely (no call, existing object
returned, createdAt / updatedAt / groupId / groupName all
untouched); a copy-mode import (no external library folders) with
the same byFilePath entry still goes through importBook so dedup
falls back to byHash. import-metahash.test.ts retains the
BookLookupIndex builder test that deleted and url-backed books
are excluded from byFilePath.
Net effect: re-importing a folder of N already-imported books does
zero file opens, zero parses, zero MD5 passes, and leaves every book's
groupId / createdAt / deletedAt / cover untouched.
donate.readest.com now lists every donation method, so the Support
section links there instead of enumerating GitHub Sponsors, Stripe,
and crypto separately.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Relocate the update and telemetry toggles out of the library settings
menu into the Behavior (Control) panel, where global app settings live:
- New "Update" boxed-list (gated on hasUpdater): Check Updates on Start
+ Nightly Builds.
- New "Privacy" boxed-list: Help improve Readest (telemetry).
- Behavior section order: Update → Security → Privacy.
- Rename "Nightly Builds (Unstable)" → "Nightly Builds" and drop the
"; may be unstable" note (the channel stays off by default).
- Updater dialog now shows the full version name (e.g.
0.11.4-2026061506) instead of a parsed date.
- Extract + translate the new strings (Update, Privacy, Nightly Builds,
Early daily builds) across all 33 locales.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): Word Wise — inline native-language vocabulary hints
Kindle-style Word Wise: a short native-language gloss renders above difficult words
as you read (always-on ruby), gated by a CEFR vocabulary-level slider (A1–C2);
tapping a glossed word opens the existing dictionary.
- Pipeline: CEFR→frequency-rank difficulty, inflection-aware gloss index, pure
offset-aware planner (EN regex + jieba for CJK).
- Rendering: <ruby cfi-skip>…<rt cfi-inert> injected per occurrence — CFI-transparent
(verified), so highlights/bookmarks/progress are unaffected; kept out of TTS word
offsets and find-in-book.
- Delivery: gloss packs are version-controlled in data/wordwise/, mirrored to R2, and
downloaded on demand into local storage (sha-verified, single-flight) when enabled.
- Settings: a Word Wise sub-page under Settings → Language (enable, level, hint
language, per-pack download/manage, auto-download toggle).
- Build tooling: scripts/build-wordwise-data.mjs (ECDICT / CC-CEDICT+HSK / WikDict +
FrequencyWords, with lemmatization) and scripts/sync-wordwise-r2.mjs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* data(wordwise): bundled gloss packs + manifest + attribution
13 frequency-trimmed gloss packs (en↔中文 + es/fr/de/pt/it/ru↔en, ~19 MB) generated
by build-wordwise-data.mjs from ECDICT (MIT), CC-CEDICT + HSK, and WikDict +
FrequencyWords (CC-BY-SA). Source of truth, mirrored to the CDN via `pnpm wordwise:sync`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The read-along audio toggle + settings gear sat in an `absolute end-0`
cluster overlaid on the centered transport row. After the #3235 read-along
feature grew that cluster from a single gear (~36px) to ~81px (audio +
divider + gear), it covered the right end of the transport on narrow
phones, hiding the audio button behind the "skip forward 15" control.
Lay the playback controls out as a single full-width flex row: the audio
toggle moves to the far left and the settings gear stays far right,
symmetrically flanking the centered play button (justify-between on
mobile, justify-center on md+). Tighten the secondary buttons on mobile
(h-8, px-1.5) and add shrink-0 so the row fits without overlap; the
symmetry keeps the play button centered.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The assemble-manifest job promoted the manifest with single-file
`rclone copyto` + `moveto`. Before a single-file upload rclone issues a
CreateBucket probe (PUT /<bucket>), which the object-scoped RELEASE_R2_*
token can't satisfy -> 403 AccessDenied, so nightly/latest.json was
never published (the build legs and the stable release flow were fine
because they use a directory `rclone copy`, which PUTs the object
directly without that probe).
Mirror the release flow (upload-to-r2.yml): copy a one-file directory
into nightly/. R2 PutObject is atomic, so the .tmp + server-side move
added nothing. Verified against the live bucket with the current token:
directory copy -> 200 OK; single-file copyto -> CreateBucket 403.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dictionaries that store only base headwords (e.g. Oxford Dictionary of
English) miss inflected selections like `ran`, `mice`, `children`, or
`analyses` even though the lemma (`run`, `mouse`, `child`, `analysis`) is
present. Add a language-aware lemmatizer whose base-form candidates are
appended to the existing lookup candidate chain, after the exact/case
variants, so an exact/case match always wins and the lemma is only tried
once those miss.
- New pluggable `lemmatize/` registry keyed by primary language subtag;
add a language by registering one lemmatizer, no caller changes.
- English lemmatizer: irregular-form table (suppletive verbs, irregular
plurals/comparatives) + regular suffix rules (plural/past/gerund/
comparative/possessive). Over-generates on purpose — the dictionary
lookup is the validator, so bogus stems simply miss.
- Unknown/missing book language defaults to English (no-op on non-ASCII);
an explicit non-English language with no registered lemmatizer is a
no-op.
- Applies centrally to all definition providers (mdict/stardict/dict/slob
and the online builtins) via `buildLookupCandidates`.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The nightly Linux legs build with `cargo tauri build` WITHOUT `--target`
(no matrix `args`), so cargo emits bundles under `target/release/bundle/`
(host-target default) rather than `target/<triple>/release/bundle/`. The
macOS/Windows legs DO pass `--target`, so they legitimately get the triple
subdir — but the "collect artifacts" step reused `${rust_target}` for the
Linux AppImage path too, looking under
`target/x86_64-unknown-linux-gnu/release/bundle/appimage/` where nothing
exists. The build succeeded; only the collect step failed with
"missing artifact or signature for linux-x86_64-appimage".
Drop the `${rust_target}` subdir from the Linux AppImage path so it points
at the host-target default location where the bundle actually lands.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Highlighting recurring text (e.g. main-character names) as global
annotations made page turning very laggy. The `progress` effect
re-fans-out every global annotation across every rendered section on
EVERY page turn, and each pass re-walks the section DOM, recomputes
`view.getCFI()` for every occurrence, and tears down + recreates an SVG
overlay per match. The overlays already exist after the first pass, so
this is pure wasted work — profiled at ~25–45ms of synchronous
main-thread time per page turn for 6 names / 226 occurrences across 2
rendered chapters, multiplied on slower mobile hardware.
Memoize, per live section `Document`, which global notes have been
expanded (signature embeds `updatedAt`/style/color/text). Subsequent
page turns short-circuit to ~0ms. Keying on the `Document` makes
invalidation automatic: a re-rendered section gets a fresh document (and
fresh overlayer) so its overlays are rebuilt, while edits/recolors bump
`updatedAt` and toggling global off clears the memo.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(reader): TTS-sync design spec for paragraph mode + RSVP (#3235)
Hardened via brainstorming + /autoplan (CEO/Design/Eng dual-voice review).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tts): emit canonical tts-position event from TTSController (#3235)
Controller emits { cfi, kind, sectionIndex, sequence } alongside the existing
tts-highlight-mark/-word events. Monotonic sequence lets downstream consumers
(paragraph mode, RSVP — later slices) drop out-of-order positions. Additive;
existing events untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): containment+cursor CFI->index mappers for TTS sync (#3235)
RSVPController.syncToCfi + setExternallyDriven: containment match (fixes
mid-token skip), monotonic cursor + binary search (avoids O(N)-per-word jank,
no per-word getCFI), -1/no-op on no match (no silent jump to word 0), timer
suspension while externally driven.
ParagraphIterator.findIndexByRange: hinted + binary-search containment mapper
returning -1 on no match (never first()).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(tts): forward tts-position + tts-playback-state onto the app bus (#3235)
useTTSControl republishes the controller's canonical tts-position (tagged with
bookKey) via a dedicated listener — NOT inside the suppression-gated highlight
handlers, so page-follow suppression can't silently desync the modes. Adds
tts-playback-state (playing/paused/stopped) so RSVP can track playback without
the hook-local isPlaying. Verified by extending the real-foliate-view browser
harness.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): paragraph mode follows TTS playback (#3235)
When paragraph mode + TTS are both active, the focused paragraph follows the
spoken position (sentence granularity, all engines). Section-generation contract
(stash cross-section position, apply after the iterator re-inits); sync-focus
path that does NOT arm isFocusingRef (avoids the relocate-eaten wrong-section
paragraph-0 bug); stale-sequence drop; decouple on manual nav, re-engage on next
playing. Start-alignment + visible indicator deferred to later slices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rsvp): speed reader follows TTS playback (#3235)
Edge word-boundary voices: RSVP shows the spoken word via syncToCfi. Non-Edge
(sentence-only) voices: sentence-paced estimator (clamp 60..600 wpm from voice
rate, hold at +60 words cap, snap to first word on each new sentence mark).
RSVP auto-advance suspended while TTS-driven. Decouple on manual nav via a
rsvp-manual-nav signal; re-engage on next playing. Cross-section positions
re-extract then apply. Pure decideRsvpTtsPosition helper unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): fixed-layout gate + ttsSyncStatus for TTS sync (#3235)
Gate sync to reflowable books (D7): fixed-layout reports 'unsupported' and
never engages. Both modes expose ttsSyncStatus (idle/following/syncing/
decoupled/unsupported) as the data source for the upcoming indicator. RSVPControl
now forwardRef-exposes the status via an imperative handle. Cross-bookKey events
ignored (regression-tested).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): 'following audio' indicator for TTS sync (#3235)
5-state pill (following/syncing/decoupled, idle+unsupported render null) shown
top-center in the paragraph overlay and as a status row in the RSVP overlay.
Decoupled state is the tap-to-resume control; first decouple fires a one-time
toast. eink-bordered, glyph+text (no color-only), RTL logical props, touch
targets, safe-area top inset. RSVP 'plain' variant matches its themed surface;
non-Edge shows '· estimated'. New i18n keys need extraction before merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(rsvp): in-overlay TTS toggle + audio-paced speed control (#3235)
Voice-glyph audio toggle in the RSVP control row (trailing, by the gear) starts/
stops read-along from inside the full-screen overlay, start-aligned to the current
word (range validated against the live doc). While TTS-driven, the WPM control
shows a locked 'Audio pace' affordance that opens a compact rate picker; rate
changes go through a new tts-set-rate bus event reusing the existing throttled
setRate path. Pure buildRsvpTtsSpeakDetail helper unit-tested.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(reader): e2e paragraph mode follows TTS across a section boundary (#3235)
Real <foliate-view> browser e2e: with paragraph mode active, the focused
paragraph follows the TTS walk and re-targets to the new section after a Ch4->Ch5
boundary (proves no stuck wrong-section paragraph-0 / isFocusingRef trap).
Asserts on the owning section of the current range. Test-only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* i18n(reader): translate TTS-sync strings across 33 locales (#3235)
Following audio / · estimated / Resume audio / Stopped following audio /
Play audio / Pause audio / Audio pace / Speed follows audio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): resolve TTS CFI anchors across iframe realms (#3235)
RSVP and paragraph follow silently failed to track the spoken word: the CFI
anchor from view.resolveCFI(...).anchor(doc) is a Range created in the book
iframe's realm, so 'anchor instanceof Range' (top realm) was always false
(cross-realm instanceof) -> resolveCfiToRange/applySyncCfi returned null ->
syncToCfi never advanced. Add isRangeLike() duck-type (cloneRange is unique to
Range) and use it at all 4 CFI-resolution sites. Confirmed live via CDP: before
= syncToCfi false (frozen); after = exact word map + RSVP follows Edge TTS at
~171 wpm (audio pace). Unit tests reproduce the cross-realm anchor (jsdom is
single-realm so the old code passed there but died in the app).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): stop estimator/word fight + map transport to TTS play/pause (#3235)
Two read-along refinements (verified live via CDP with Edge TTS):
1. No more jump-ahead-then-snap-back flashing. Word-boundary engines (Edge) emit
BOTH sentence marks and word boundaries; RSVP was routing sentence -> the
estimator (self-paces ~190xrate, up to +60 words ahead) while word positions
snapped it back. Now once a word position is seen, sentence positions are
ignored and any running estimator is stopped, so words alone drive RSVP.
2. The RSVP transport (center play/pause, Space, center-tap) maps to TTS
play/pause while read-along is engaged (tts-toggle-play), instead of RSVP's
own suspended timer. Pausing TTS keeps RSVP suspended (no runaway); a full
stop releases it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): keep indicator on pause + reach dict management from RSVP (#3235)
- Pausing read-along no longer dismisses the 'following audio' indicator / 'Audio
pace' lock (layout shift). New 'paused' sync status keeps the indicator row and
WPM lock present while TTS is engaged-but-paused; only a full stop clears them.
Verified live via CDP: pause keeps the layout, no shift.
- Dict management is reachable from RSVP: the settings dialog is z-50, far below
the full-screen RSVP overlay (z-[10000]), so it opened invisibly behind it.
handleManageDictionary now exits RSVP first (position saved/resumable) so
management shows over the reader.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(rsvp): show dict management over RSVP instead of exiting it (#3235)
Per feedback: opening dictionary management from the RSVP lookup popup no longer
closes the speed reader. The settings dialog is raised above the full-screen RSVP
overlay (z-[10000] -> SettingsDialog !z-[10050]) so it shows on top, and RSVP's
capture-phase keyboard handler bails while the settings dialog is open so its
inputs accept Space and Escape closes settings (not RSVP). Verified live via CDP:
management opens over RSVP, RSVP stays active behind it, Escape returns to it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(dict): only apply drag-handle margin compensation when the handle shows (#3235)
The dictionary sheet header used -mt-4 to compensate for Dialog's drag handle,
but that handle is sm:hidden (shown only below sm). On sm+ the handle is
display:none, so -mt-4 pulled the header up into the top edge (broken layout
when the lookup renders as a sheet on a short/wide window). Mirror the handle's
breakpoint: -mt-4 sm:mt-0. Verified live via CDP.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): in-mode TTS audio toggle for paragraph mode (#3235)
Paragraph mode already follows TTS, but there was no way to start read-along
from inside it. Add an audio toggle to the ParagraphBar (mirroring RSVP's): it
starts TTS start-aligned to the focused paragraph (range validated live, +
section index) and stops it. Track session-active vs playing so a pause keeps
the indicator ('paused' status) instead of collapsing to idle. Pure
buildParagraphTtsSpeakDetail helper unit-tested. Verified live via CDP: tapping
the icon starts audio from the focused paragraph and the focus follows speech.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(reader): highlight current TTS word/sentence in paragraph mode (#3235)
Paragraph mode follows TTS by advancing the focused paragraph, but the spoken
word wasn't highlighted within it like normal mode. The overlay renders a CLONE
of the paragraph, so the iframe's TTS highlight isn't visible there — reproduce
it on the clone with the CSS Custom Highlight API (no DOM mutation, spans inline
boundaries natively, leaves the fade-in animation untouched).
- TTSController already tags tts-position with kind word|sentence. The hook
decides granularity: word boundaries (Edge) drive a per-word highlight; once
seen, the coarse sentence event is skipped so the whole sentence doesn't
flicker over the current word. Engines without word boundaries
(WebSpeech/Native) fall back to the sentence highlight.
- Offsets are computed relative to the paragraph start (so they map 1:1 onto the
clone's text) and tagged with the paragraph index so a stale highlight never
paints the wrong paragraph. Cleared on stop / section change / disabled.
- The ::highlight() style mirrors the user's ttsHighlightOptions color+style.
Pure helpers (offset math, word/sentence decision, css builder) unit-tested.
Verified live via CDP: word highlight tracks Edge word-by-word and follows
across paragraph boundaries (news -> ... -> ladies), matching the TTS color.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mounts the real foliate <foliate-view> with sample-alice.epub, renders
the real useTTSControl hook with the real stores, and mocks only the
speech client. Starts TTS at the last paragraph of chapter 4 and
verifies the reading auto-advances into chapter 5, the page turns, and
the "Back to TTS Location" badge never appears (the TTS location stays
in view).
The mock client's speak() only needs to yield `end` — the real
TTSController drives forward() and the real view.tts walks the document
across the section boundary, so the cross-chapter navigation and badge
suppression are genuinely exercised rather than re-implemented in the
test.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): refresh book cover after editing metadata
Editing a book's cover in Book Details and saving showed the old cover
until a full reload, in two render paths:
- Library grid: handleUpdateMetadata mutated the book object in place,
so the memoized <BookCover> compared fields off the same (mutated)
reference and skipped re-rendering. Build a new book object via the
new getBookWithUpdatedMetadata helper instead of mutating.
- Book Details view: BookDetailView renders cover/title/author from the
modal's `book` prop, which the parent never re-passed after save.
BookDetailModal now tracks the saved book locally (displayBook) and
renders the view from it.
Adds a unit test for the immutable helper, a BookDetailModal regression
test (edit cover -> save -> view reflects it), and a sample-alice.txt
fixture for TXT import testing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore(agent): add cover-refresh stale-render memory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>