96d65d9960ea12fa74163e82ad4f462566d4e6bb
1319 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
96d65d9960 |
feat(tts): add native local iOS TTS (AVSpeechSynthesizer) (#4697)
Implement on-device iOS text-to-speech using AVSpeechSynthesizer, mirroring the Android native TextToSpeech plugin so the shared NativeTTSClient drives both platforms through the same command and tts_events contract. - Swift NativeTTSPlugin: speak/stop/pause/resume/rate/pitch/voice and voice enumeration, with region-disambiguated duplicate voice names and a small preUtteranceDelay to avoid first-word clipping. - Enable the native TTS client on iOS in TTSController. - Make TTS teardown resilient: reset UI state up front and tear down the controller, media session, and background audio in parallel so a slow native shutdown can never leave the TTS icon or lock-screen session stuck on. - Keep iOS on navigator.mediaSession for the lock screen (Android uses the native foreground service), which restores the Edge TTS cover and current-sentence metadata. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ab935f8510 |
fix(library): preserve original files when deleting "read in place" books (#4696)
Deleting a book imported via "Import From Directory" with "Read books in place" ran fs.removeFile on the user's own source file, permanently destroying the original (it was not even moved to the Recycle Bin). Cloud sync only uploads after a successful sync, so unsynced originals were unrecoverable. deleteBook now only removes files Readest created: the managed copy under Books/<hash>/ and the app-generated sidecars (cover.png, plus the whole Books/<hash>/ dir on purge). External sources (book.filePath, base 'None', covering in-place and transient imports) are never touched. This reverses behavior that was previously deliberate and tested; the in-place tests now assert the source file is preserved across local/both/purge while sidecar removal is unchanged. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
9e163fe746 |
fix(payment): reflect highest active plan across overlapping Stripe subscriptions (#4694)
* fix(payment): reflect highest active plan across overlapping Stripe subscriptions When a user upgrades Plus to Pro, both subscriptions stay active until the old one is cancelled. Each subscription webhook overwrote plans.plan with only that event's plan, so whichever webhook arrived last won and could downgrade the account back to plus. Derive plans.plan from the highest active (or trialing) subscription via a new getHighestActivePlan helper, used by createOrUpdateSubscription and by the cancellation handler so a still-active higher plan is preserved instead of dropping to free. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(payment): add opt-in live Stripe test for getHighestActivePlan Skipped by default (CI included); runs only when STRIPE_SECRET_KEY and STRIPE_TEST_CUSTOMER_ID are set, so it can be exercised locally against a real customer with overlapping subscriptions. The module under test is imported dynamically so the file stays import-safe while skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7185dca1a2 |
feat(reader): add save/share button to image gallery toolbar (#4680)
* feat(reader): add save/share button to image gallery toolbar Add a button to the top-right toolbar of the fullscreen image viewer that saves the currently viewed image to the device. It uses the native or web Share flow where available (iOS/Android/macOS, navigator.share) and falls back to a save dialog or browser download otherwise, reusing the existing export path via appService.saveFile. The button icon and label reflect the active flow (share vs save). Adds dataUrlToBytes/imageExtensionFromMime helpers, unit and component tests, and translations for the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(share): write shareable file to a Temp subdirectory to avoid 0-byte share On Android, Tauri's Temp dir is the app cache dir, and the sharekit plugin copies the shared file to <cacheDir>/<name> before firing the share intent. When saveFile wrote the shareable file to the Temp root, that copy became a copy onto itself whose output stream truncated the source to 0 bytes, so the shared image (and any shared export) arrived as a 0 KB file. Write the file to a Temp subdirectory instead so the plugin's copy has a distinct source. Verified on a Xiaomi device: sharing a file in the Temp root truncated it to 0 bytes, while sharing from the subdirectory produced a real, non-empty copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(reader): save image to system gallery on Android The Android share sheet cannot save an image to a file (no file manager registers as an ACTION_SEND target), so the Save Image button now writes the image straight into the system photo gallery via MediaStore. It lands in Pictures/Readest, visible in Gallery and the Files app, with no picker and no storage permission on Android 10+. Adds a save_image_to_gallery command to the native-bridge plugin (Rust + Kotlin MediaStore insert) and an appService.saveImageToGallery method. On Android the Save button uses it; iOS/macOS/desktop/web keep the existing share/export flow, and the button label/icon reflect the actual action. Also includes local agent memory notes that were staged alongside. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5f561504e3 | fix(sync): keep view settings device-local and exclude them from sync (#4672) | ||
|
|
b9a3ee725f |
fix(opds): make saved catalog card hover distinct from dialog background (#4673)
The saved catalog cards used hover:bg-base-200/40. Since base-200 is only ~5% off base-100, applying it at 40% alpha shifted the background by roughly 2%, and with the dialog itself sitting at base-200 the hover collapsed into the dialog color, making the hovered card blend in. Use hover:bg-base-300 (~12% off base-100) so the hover state is clearly separated from both the resting card (base-100) and the dialog (base-200). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
23d1ef6f13 |
fix(rsvp): restore in-flow control bar layout reverted by #4589 (#4671)
* fix(rsvp): restore in-flow control bar layout reverted by #4589 PR #4585 fixed the mobile RSVP control bar overlap by laying the audio toggle and settings gear in a single in-flow flex row flanking the centered transport. PR #4589 branched from main about five minutes before #4585 merged and merged about ten hours later without rebasing, so its squash carried the stale pre-#4585 file and reverted the entire fix, including the regression test #4585 had added. On narrow phones (360px) the audio and settings icons again overlapped the right end of the transport, hiding the "skip forward 15" control. Restore the #4585 layout and re-add a structural guard test asserting the audio toggle and settings share the transport row and live in no absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via on-device CDP: no overlap, play button stays centered. Also stage the project-memory note for this regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): hide Faster/Slower buttons at 350px or below On very narrow phones (width 350px or less) the control row has no room for every control. Collapse the Faster/Slower speed buttons via a max-[350px]:hidden variant (matching the existing 350px tightening tier) so the transport, audio toggle and settings never overflow. Speed stays adjustable from the WPM dropdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
d5c640996d |
fix(opds): show Add Catalog dialog above Settings on mobile (#4669)
The "Add OPDS Catalog" dialog (a ModalPortal opened from inside Settings > Integrations > OPDS Catalogs) rendered behind the Settings sheet on mobile, so the form could not be reached or filled in. Root cause: PR #3235 raised the Settings dialog to z-[10050] to clear the full-screen RSVP overlay (z-[10000]) for in-overlay dictionary management. That also jumped Settings above the ModalPortal layer (z-[100]), so any modal opened from inside Settings was buried. The bug is mobile-only because on desktop the rounded-window frame (.window-border, z-99) traps the inline-rendered Settings dialog in its own stacking context, while ModalPortal escapes to document.body and wins there. Redesign the overlay z-index into a compact scale (no four-digit values), each layer clearing the z-99 page frame: 100 RSVP overlay 101 RSVP controls (start dialog, lookup chip) 110 Settings dialog 120 modal / command palette 130 toast / alert 200 app lock Lock the ordering with a static test that reads the values from source and would have caught the #3235 regression. Documented in DESIGN.md. Verified on a Xiaomi device via CDP: elementFromPoint at the dialog center now resolves inside the Add Catalog form instead of Settings. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
dd53e52453 | chore: only show the current position item in TOC and update agent memories (#4665) | ||
|
|
6caa376f82 |
feat(reader): Webtoon Mode seamless continuous scroll for image books (#3647) (#4662)
* feat(reader): make fixed-layout scroll gap configurable (foliate-js bump) (#3647) * feat(reader): add webtoonMode view setting + scroll-gap helper (#3647) * feat(reader): Webtoon Mode toggle in the fixed-layout view menu (#3647) * feat(reader): apply Webtoon Mode gap on fixed-layout book open (#3647) * fix(reader): clear Webtoon Mode + reset gap when Shift+J leaves scrolled (#3647) * chore(i18n): translate Webtoon Mode string across locales (#3647) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(deps): bump foliate-js to merged readest/foliate-js#30 (#3647) --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
86f5502724 |
fix: bot-review robustness fixes (TTS sync, updater, nightly, a11y) (#4659)
Cherry-picked and re-verified the applicable subset of julianshen/readest@fa1b74a0 (its "address PR #15 bot reviews" commit). The fork-only AI-annotation-tool change was dropped — readest has no 'ai' toolbar tool. Each logic fix is covered by a failing-first test. - TTS position sequence is now an app-wide monotonic counter, so a fresh TTSController (constructed per `tts-speak`) isn't dropped by consumers holding `lastSequenceSeen` from a prior session. - share.ts only swallows AbortError (user cancel); other failures — e.g. NotAllowedError when a quick action fires without a user gesture — fall back to the clipboard so the text still reaches the user. - document.isTxt tolerates MIME params (text/plain;charset=utf-8), uppercase extensions (BOOK.TXT), and a nameless Blob, so a TXT can't slip onto the non-text path and yield a null book. - updater getNightlyPlatformKey matches x86_64/aarch64 explicitly; a 32-bit or otherwise unknown arch yields no nightly instead of mis-routing to aarch64. - UpdaterWindow downloadWithProgress resolves on tauriDownload completion even when Content-Length is absent (no more hang on portable/AppImage/Android). - nightly_update.rs uses async tokio::fs::read in the async command. - nightly.yml: serialize runs via a concurrency group (no cancel) and persist-credentials:false on checkouts. - edge TTS route only emits the word-boundary header when it fits under ~8KB; oversized values get dropped by proxies, and the client falls back to []. - RSVPOverlay drops the contradictory aria-disabled on the functional rate button (it opens the pace picker). - nightly verify harness handles artifact stream errors instead of crashing. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e327d0c992 |
feat(tts): reuse the speaking session across paragraph & RSVP modes (#4657)
Switching into Paragraph or RSVP mode while TTS is already playing now syncs to the live session instead of forcing a stop + restart inside the mode. Bundles several related TTS fixes uncovered along the way. Session reuse (enter from normal mode): - TTSController.redispatchPosition() re-emits the current position on the canonical tts-position signal with a fresh sequence. - useTTSControl answers a new tts-sync-request by replaying the current position then playback state (position-first so RSVP's paused handler can't discard it). - Paragraph & RSVP engage following on entry and dispatch the request; no-op when no session exists. RSVP refinements: - Reusing a session skips the start dialog and the get-ready countdown (starts externally driven); gated on a live tts-playback-state signal so the countdown can't flash. - Stopping TTS now pauses RSVP instead of resuming its own pacing. Word-sync fixes: - rangeTextExcludingInert honours the range offsets inside a single text node, fixing word-highlight drift on middle sentences of single-<span> paragraphs (Edge word highlighting). - foliate-js TTS.from() starts at the sentence containing the selection, not the next one (submodule bump). - Selecting a word and starting TTS now clears the selection. - Dev-only [TTS] word-sync trace (stripped from production builds). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
72233e1c6a |
feat: sync reading status across devices and with KOReader (#4634) (#4656)
* docs(sync): design spec for syncing reading status (#4634) Field-level LWW for reading_status (dedicated reading_status_updated_at), a new 'abandoned' status in the Readest UI, and a koplugin bridge to KOReader's native summary.status (whole-library apply + capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): implementation plan for syncing reading status (#4634) Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW (reading_status_updated_at on server upsert + client pull-merge), B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge to KOReader summary.status (mapping + reconcile + whole-library apply/capture). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): add reading_status_updated_at for field-level status LWW (#4634) * feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): stamp status timestamp on explicit library status edits * feat(sync): resolve reading status by its own timestamp in client pull-merge * feat(sync): resolve reading status by its own timestamp in server upsert (#4634) * fix(sync): tighten reading-status merge typing + strengthen test (A5 review) Replace as-unknown-as double-casts at read sites with typed locals (clientBook/serverBook); retain a single as-unknown-as only at the server-wins construction site where the static type is too narrow. Strengthen test 3 to assert both fields with toEqual. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(library): render the 'On hold' (abandoned) status badge * feat(library): add 'Mark as On hold' actions + i18n for abandoned status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader * feat(koplugin): persist + sync reading_status_updated_at in LibraryStore Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634) * test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634) KOReader auto-sets summary.status='reading' on first open, and legacy Readest statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a finished book downgrade it. Restrict sync to deliberate statuses (finished/ complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'. On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative, then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns write_ko/write_store flags; statussync captures now_ms once and equalizes both sides (convergent, idempotent, resumable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(koplugin): cover remaining first-sync graph cells + document sort effect (review) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
be5862f08c |
fix(library): group secondary series sort by series name then index (#4652) (#4653)
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> |
||
|
|
6c7c86f346 |
feat(applock): biometric unlock (fingerprint / Face ID) at startup on mobile (#4650)
* feat(applock): wire up biometric plugin + biometricUnlockEnabled setting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): add guarded biometric service wrapper * feat(applock): auto-prompt biometrics on the lock screen with PIN fallback Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(applock): guard concurrent biometric prompts + tighten lock-screen tests - Add biometricInFlightRef to prevent concurrent authenticateWithBiometrics calls - Assert PIN input still rendered after biometric failure (test 2) - Replace flaky waitFor negative assertion with a 50ms flush in test 3 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(applock): mobile biometric toggle + default-on at PIN setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(i18n): add biometric app-lock strings * fix(applock): seed biometricUnlockEnabled via app-lock store init; close test gaps Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * build(applock): pin tauri-plugin-biometric in Cargo.lock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
be17654fc0 |
fix(rsvp): render RTL words whole so Arabic shapes correctly (#4630) (#4648)
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> |
||
|
|
ff96c6d3f7 |
feat(annotations): unify highlights and annotations into one record (#3870, #4511) (#4647)
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> |
||
|
|
38a6d3d9ba |
fix(dict): stop iOS instant system dictionary popping multiple times (#4644)
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> |
||
|
|
495783d045 |
fix(security): harden OPDS proxy SSRF, storage key validation, Stripe check (#4638)
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> |
||
|
|
8bcb9f9b2a |
feat(wordlens): trim hints to first sense + suppress known derivations (#4635)
* 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> |
||
|
|
c2ac207945 |
refactor(wordlens): rename "Word Wise" to "Word Lens" (#4633)
"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>
|
||
|
|
b31699d052 |
fix(library): restore back button from series/author folders (#4437) (#4629)
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>
|
||
|
|
fa120081a1 |
fix(library): never let a routine save shrink library.json (cold-start "Open with" wipe) (#4627)
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> |
||
|
|
d5c02e6253 |
feat(library): add Purge Data and fold detail actions into a More menu (#4615) (#4626)
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> |
||
|
|
d202d7a61e |
feat(library): add Clear Pending action to transfer queue (#4617)
* 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> |
||
|
|
480ab5b71e |
feat(hardcover): automatically sync progress and notes (#4614)
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> |
||
|
|
757ed8066b |
feat(library): show series and number in list view (#4593) (#4612)
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>
|
||
|
|
a30a310a17 |
fix(opds): handle entries with no downloadable format (#4599) (#4611)
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> |
||
|
|
35b02c4efc |
feat(statistics): KOReader-compatible reading stats with cross-device sync (#4606)
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> |
||
|
|
e145eb835a |
feat(reader): open image gallery & table zoom on single tap (#4600)
* 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> |
||
|
|
675ee78bc9 |
perf(library): in-place re-import is a no-op on the same file path (#4597)
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.
|
||
|
|
79496f88d7 |
feat(settings): move update & telemetry controls into Settings → Behavior (#4592)
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> |
||
|
|
4908245042 |
feat(reader): Word Wise inline vocabulary hints (#4589)
* 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> |
||
|
|
51fede1a0d |
fix(rsvp): keep the audio toggle from overlapping transport on mobile (#4585)
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> |
||
|
|
aab721b219 |
feat(dictionary): lemmatize inflected words before lookup (#4574) (#4582)
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> |
||
|
|
0f0b4279a7 |
perf(reader): memoize global-annotation fan-out per section (#4575) (#4579)
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> |
||
|
|
57501cc520 | feat(updater): nightly update channel (Android/Windows/macOS/Linux) (#4577) | ||
|
|
bfb85c2f68 |
feat(reader): sync paragraph mode & speed reader with TTS read-along (#3235) (#4576)
* 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> |
||
|
|
5a8f0873fa |
fix(library): refresh book cover after editing metadata (#4572)
* 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> |
||
|
|
67c22c770b |
feat(reader): Share intent + customizable annotation toolbar (#4014) (#4570)
* docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
a56cc6c61a |
feat(tts): word-by-word highlighting for Edge TTS, closes #4017 (#4566)
Highlight each word as it is spoken (Edge TTS only) instead of keeping the whole sentence highlighted, and keep the view tracking the spoken word across page boundaries. Word boundaries - Capture Edge's audio.metadata WordBoundary frames (offset/duration in 100ns ticks plus the verbatim input-text span) in the Tauri, browser, and Cloudflare-Workers WebSocket transports. - Carry boundaries through the authenticated HTTPS proxy route via an X-TTS-Word-Boundaries response header (percent-encoded JSON, ASCII-safe), so word highlighting works on the web where the browser cannot open the wss connection directly. Cache them alongside the audio blob URL. Highlighting - Sync a requestAnimationFrame loop to audio.currentTime against the boundary table and highlight the word sub-range within the spoken sentence. Synthesis stays sentence-level (natural prosody); only the visual highlight is word-level. - Suppress the sentence highlight when the active client reports word boundaries and draw the first word immediately, so the whole sentence never flashes before the first word. Fall back to the sentence highlight when a chunk has no boundaries (other engines, empty metadata). - Re-apply the current word (not the sentence) when the view relocates. Page following - Turn the page as soon as the spoken word crosses a page boundary (a tts-highlight-word event scrolls only when the word is outside the visible range), instead of waiting for the next sentence. - Check the word's position for the "back to TTS location" badge so it no longer appears while the view follows the word onto the next page. Also fixes a pre-existing bug where the browser WebSocket was constructed with an options object (valid only for the Node ws package), which threw in browsers and made the wss path unusable on the web. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7f57af8f90 |
perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher
When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.
Fix:
- createCfiLocationMatcher(location) collapses once and returns a
matches(cfi) predicate that reuses the cached bounds. O(N) calls
become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
(no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
useMemo([config.booknotes]) once when booknotes change, not per
page turn. The progress-driven effect then only scans the current
chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
the same reason.
Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.
* fix(annotator): keep note-only annotations in the per-chapter bucket
The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.
Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).
Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.
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>
|
||
|
|
7cba22ab31 |
perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm (#4562)
* perf(reader): coalesce relocate events and memoize BookCell to stop per-swipe storm
FoliateViewer
-------------
foliate fires `relocate` multiple times during a swipe burst (snap
steps + intermediate stabilize). Each one ended up in setProgress,
which writes to readerProgressStore + bookDataStore. Coalesce them to
a single commit per animation frame so only the final viewport state
is persisted.
Earlier this used requestIdleCallback, but profiling on Android showed
"Fire Idle Callback" ballooning to 2.0+ s of total time per ~28 s
session: rIC backed up under sustained pressure and dumped the whole
queue into the post-swipe pause, producing exactly the "feels sluggish
right after I let go" jank we were trying to fix. rAF runs once per
frame, gets scheduled by the browser's normal vsync loop, and doesn't
accumulate when the page is busy.
BooksGrid -> BookCell
---------------------
Previously BooksGrid subscribed to the entire progresses map and
rendered every book inline. The map changes on every page turn, so the
whole bookKeys.map(...) body re-ran for every swipe. On top of that
inset-related objects (gridInsets, contentInsets) were rebuilt every
render and threaded as fresh references into 7+ children, so even
unchanged children couldn't bail out. That accounted for ~27% of
main-thread time in the Bottom-Up profile ("Animation Frame Fired"
2.6s / 27%).
Extract BookCell as its own React.memo'd component:
- Each cell subscribes only to its own book's progress via
useBookProgress(bookKey). A page turn re-renders one BookCell, not
the grid.
- viewInsets / contentInsets are memoized off their numeric inputs so
children get stable prop references across renders.
- BookCell uses per-field selectors internally for the same reason
spelled out in store/readerProgressStore.ts header.
- Dropdown handlers are wrapped in useCallback so HeaderBar's props
object stays stable.
* fix(reader): subscribe BookCell to its own viewState so settings/ribbon toggles apply live
BookCell subscribed reactively only to useBookProgress and read
viewState/viewSettings imperatively. Settings that save with
applyStyles=false (Show Header/Footer, Double Border, Border Color) and
the bookmark ribbon toggle write no progress, so the cell didn't
re-render and the chrome it gates (SectionInfo, ProgressBar, DoubleBorder,
Ribbon) only updated on the next page turn.
Subscribe to the per-book viewStates[key] slice. This is safe now that
progress lives in its own store — viewStates[key] only bumps on
low-frequency events (settings toggles, ribbon, init, sync), never on
the per-swipe relocate path — so it does not reintroduce the commit
storm the progress-store split removed.
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>
|
||
|
|
ee01fcd123 |
fix(reader): texture the scrolled-mode top inset mask, closes #4486 (#4563)
In scrolled mode the notch-area masks the top safe-area inset with opaque bg-base-100 so content scrolling under the status bar is hidden, but it painted over the background texture (.foliate-viewer::before at the z-0 layer), leaving a flat untextured strip across the unsafe header area. Give the mask its own texture ::before (.notch-masked in textures.ts) and make the element span the grid cell, clipped down to the inset strip with clip-path — background-size cover/contain resolves against the element box, so the full-cell box is what keeps the mask's tiles aligned with the viewer's at the seam. clip-path also clips hit-testing, so the click target stays the inset strip only. Verified on a Xiaomi 13: the strip now renders the texture with a pixel-continuous seam (row-to-row MAE at the boundary dropped from 11913 to 230, the level of ordinary texture rows), and elementsFromPoint confirms the notch is hit-testable only inside the strip. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
59d4f0aa33 |
perf(reader): split progress into its own store to cut React commit storm (#4557)
setProgress was called multiple times per swipe burst, each call writing into readerStore.viewStates[key].progress. ~65 places in the reader subtree subscribed to useReaderStore() without a selector, so every setProgress fan-out re-rendered all of them -- even the 51 that didn't care about progress. On Android release builds this showed up as Layout = 9.8% and Function Call = 9.6% of main-thread self time in Chrome DevTools' Bottom-Up profile during a reading session. Fix: - New tiny store store/readerProgressStore.ts holds the per-book BookProgress map. setBookProgress only fires its own subscribers. - readerStore.setProgress now writes progress to the new store and only touches bookDataStore for the primary view (secondary parallel views shouldn't overwrite the shared config). - readerStore.getProgress is kept as a delegating facade so existing imperative call sites don't break. - Components / hooks that genuinely need to react to progress changes subscribe via the new useBookProgress(bookKey) hook. The handful of call sites that just want a one-shot read use getBookProgress(key) so they don't subscribe at all. - readerStore.clearViewState calls clearBookProgress so the map doesn't grow unbounded across book opens/closes. See store/readerProgressStore.ts header for the full rationale. |
||
|
|
1ce79d9abf |
perf(reader): reduce open-book TBT by batching layout-thrashing reads/writes and deferring annotation page back-fill (#4554)
* perf(reader): batch keepTextAlignment reads/writes to avoid layout thrashing
keepTextAlignment iterates every <div>, <p>, <blockquote>, <dd> in a
freshly-loaded section and tags each with an aligned-{center,left,
right,justify} class based on its computed text-align. The previous
implementation read getComputedStyle and wrote classList.add inside
the SAME forEach pass, which is the textbook layout-thrashing
anti-pattern: classList.add invalidates the document's style cache
(class-based selectors can affect descendants), so the next
getComputedStyle call forces the browser to recompute style for the
whole document.
For a long chapter (~hundreds of p/div/blockquote/dd elements — a
typical Harry Potter section), that turned the loop into N x layout
recalcs. On a release Android build it surfaced as:
- Browser console violation: 'Forced reflow while executing
JavaScript took 1210ms'
- The dominant chunk of the open-book Bottom-Up profile's
Layout = 32.8% / Recalculate Style = 17.5% of TBT (2503ms total)
- The 'load' handler also tripped a 1249ms violation, dominated by
keepTextAlignment running inside it
Fix: split into a read pass (O(N) getComputedStyle into an array) +
a write pass (O(N) classList.add). The browser computes style once
for the document at the start of the read pass and reuses that
result for every subsequent getComputedStyle call; the write pass
then batches all class mutations together so style invalidation
happens at most once at the end.
* perf(reader): back-fill annotation pages off the open-book hot window
Each call to view.getCFIProgress(cfi) synchronously decompresses the matching section's XHTML from the EPUB zip and walks its text nodes (foliate-js progress.js #getCache), costing 100-300ms per cold section on a release Android build. For users with annotations spread across many chapters that's seconds of zip-IPC + main-thread work that was happening inside the open-book TBT window.
First attempt scheduled the back-fill via requestIdleCallback. On Android Tauri the WebView fires rIC aggressively while the main thread is still doing layout/style work for the freshly-opened book — the Bottom-Up profile after that change still showed 1.5s+ of sendIpcMessage -> readData -> loadDocument -> getCFIProgress chains nested under "Fire Idle Callback" inside the same hot window.
New strategy:
- Hard gate on the renderer's first 'stabilized' event so the back-fill can't possibly start before the open-book paint settles.
- Add a 5s grace timer after stabilized so the user's first page-turns and paginator's adjacent-section preload can finish without contention.
- Process annotations one at a time with a 250ms setTimeout gap between each, instead of chained idle callbacks. Each getCFIProgress shows up as its own short task with input-handling slots in between.
- 10s safety-net fallback if 'stabilized' never arrives, plus full cleanup on unmount.
- Batch the saveConfig write at the end (one IPC instead of N).
- Skip entirely when there are no annotations missing a page.
The page field still only feeds the secondary 'p NN ·' label in the sidebar BooknoteItem, so the on-screen highlight rendering paths (progress-driven addAnnotation in the [progress] effect, plus onCreateOverlay on section load) are completely independent and unaffected by this change.
|
||
|
|
9dc41e7adf |
feat(reader): reference page numbers from EPUB page-list with manual page count fallback (#4549)
Add a 'Reference Pages' reading progress style that shows physical book page numbers in the footer progress info: - When the book carries a page list (EPUB3 nav page-list or EPUB2 NCX pageList — foliate-js already parses both and resolves the current pageItem on relocate; it was just never consumed), display the current page label and use the highest numeric label as the total, so a trailing roman-numeral index page can't corrupt the total (#672). - When the book has none, a per-book 'Reference Page Count' input appears; the reading fraction is mapped linearly onto the entered count (#4542). The count is saved per book only and never propagates to global view settings. - Falls back to percentage display when neither source is available. Verified with the sample books from #672: Caleb's Crossing (EPUB3 page-list, 419 pages) and Count Zero (EPUB2 NCX pageList/page-map, 346 pages — chapter 2 lands exactly on page 22 per its page-map), plus a stripped no-pagelist copy for the manual-count path (175/350 at 50%). Closes #672 Closes #4542 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ceddee3793 |
feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
Adds a quick "Search on Goodreads" action so readers can jump straight to Goodreads to track a book instead of retyping the title there. - Library: a Goodreads button in the Book Details view (works on web, desktop and mobile) searching the book's title + author, plus a "Search on Goodreads" item in the desktop right-click context menu. - Reader: Goodreads is added as a built-in web-search provider so highlighted text (e.g. a short-story title inside a magazine) can be looked up on Goodreads. Disabled by default like the other built-ins; enable it in Settings -> Dictionaries. Both surfaces are used because the native context menu is desktop-only; the Book Details button covers web and mobile. Adds a shared openExternalUrl() helper and translates "Search on Goodreads" across all locales (the Goodreads brand name is kept verbatim). Closes #4543 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
cfe2bb9116 | fix(reader): Android text selection breaks on the first word of hyphenated paragraphs (#4545) | ||
|
|
390c711070 |
feat(rsvp): configurable start delay, word stepping, context dictionary lookup, and keyboard shortcut (#4541)
- #4478: add a configurable pre-start countdown (Off / 1s / 2s / 3s, default 3s) that now ticks at honest one-second intervals and applies to start, resume, and page loads; Off starts instantly. - #4476: add manual next/previous word controls (buttons flanking Play plus the "," / "." keys) that pause playback and step exactly one word. - #4475: allow selecting text in the context panel to look it up in the dictionary (anchored popup on desktop, bottom sheet on small screens), reusing the reader's dictionary view; auto-scroll/seek are suppressed during selection and an outside click dismisses the popup. - #4473: add a Speed Reading keyboard shortcut (Shift+V), shown on the View menu item; ignore repeat triggers while a session is active. Adds i18n strings for the new UI across all locales. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |