chore(agent): update agent memories (#4833)

This commit is contained in:
Huang Xin
2026-06-28 11:11:46 +08:00
committed by GitHub
parent 69599e2bcc
commit ae03be96d0
16 changed files with 464 additions and 40 deletions
+28 -40
View File
@@ -1,12 +1,8 @@
# Readest Project Memory
## Key Reference Documents
- [Bug Fixing Patterns](bug-patterns.md) — categories, root causes, fixes
- [CSS & Style Fixes](css-style-fixes.md) — EPUB CSS + style.ts
- [TTS Fixes](tts-fixes.md) — TTS architecture + bugs
- [Layout & UI Fixes](layout-ui-fixes.md) — safe insets, z-index, platform UI
- [Platform Compat Fixes](platform-compat-fixes.md) — Android/iOS/Linux/macOS
- [Annotator & Reader Fixes](annotator-reader-fixes.md) — highlight, selection, a11y
## Key Reference Documents (aggregators)
- [Bug Patterns](bug-patterns.md) · [CSS & Style](css-style-fixes.md) — EPUB CSS + style.ts · [TTS](tts-fixes.md) — architecture + bugs
- [Layout & UI](layout-ui-fixes.md) — insets/z-index · [Platform Compat](platform-compat-fixes.md) — Android/iOS/Linux/macOS · [Annotator & Reader](annotator-reader-fixes.md) — highlight/selection/a11y
## Safety & Security
- [In-place delete wiped originals](in-place-delete-wiped-originals.md) — never `fs.removeFile` `external` source; managed copy + sidecars only
- [Backup zip Windows paths (#4703)](backup-windows-zip-paths-4703.md) — `\` entry names broke restore; normalize separators
@@ -26,29 +22,25 @@
- [PDF scrolled wheel double (#4727)](pdf-scroll-mode-wheel-double-4727.md) — drop manual `scrollBy` over native chaining
- [Scrolled header title center (#4436)](scrolled-header-title-center-4436.md) — view covering `renderedStart + size/2`
- [Duokan fullscreen cover scroll](duokan-fullscreen-cover-scroll.md) — #4379 cover collapses; gate on `this.#column`
- [TOC table heading clip (#4439)](toc-table-heading-clip-4439.md) — #4400 scroll-wrapper `overflow:auto` clips decorative table's `margin:-1em` bleed; `hoistNegativeMargins` onto wrapper
- [Page-turn bg-replace reflow (#4785)](pageturn-bg-replace-reflow-4785.md) — `#replaceBackground` rebuilt context per frame (getComputedStyle + per-view getBCR), worst at boundaries; snapshot `#bgAnimContext` once per gesture (drag+snap); defer mid-drag preload via `!#touchScrolled`
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` — central EPUB CSS transformation hub
- `packages/foliate-js/paginator.js` — page layout, image sizing, backgrounds
- `src/services/tts/TTSController.ts` — TTS state machine, section tracking
- `src/hooks/useSafeAreaInsets.ts` — safe area inset management
- `src/app/reader/components/FoliateViewer.tsx` — reader view orchestration
- `src/app/reader/components/annotator/Annotator.tsx` — annotation lifecycle
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` layout/image/bg · `src/services/tts/TTSController.ts` TTS state machine
- `src/hooks/useSafeAreaInsets.ts` insets · `src/app/reader/components/FoliateViewer.tsx` view orchestration · `.../annotator/Annotator.tsx` annotation lifecycle
## Sync Notes
- [Grimmory native sync](grimmory-native-sync.md) — Booklore-fork REVERTED; id by ISBN/ASIN + koreader-hash
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) convert via CFI's own spine, not `new XCFI(primaryDoc,...)`
- KOSync: [CFI spine resolution](kosync-cfi-spine-resolution.md) convert via CFI's own spine; [connect() false-positive #4692](kosync-connect-false-positive-4692.md) validate koreader JSON not any 2xx
- [Empty-start CFI sync](empty-start-cfi-sync.md) — `isMalformedLocationCfi` → discard synced value
- [Custom fonts vanish on sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins; re-import `reincarnation` token
- [koplugin note deletion sync](koplugin-note-deletion-sync.md) `recordDeletion` tombstone; `index_modified < 0`
- [koplugin stats sync (#4666)](koplugin-stats-sync.md) — statistics.sqlite3 delta; LuaSettings/required/optional_params
- koplugin: [note deletion](koplugin-note-deletion-sync.md) `recordDeletion` tombstone; [stats #4666](koplugin-stats-sync.md) statistics.sqlite3 delta LuaSettings; [bulk download #4751](koplugin-bulk-download-4751.md) `downloadAll()`/`coroutine.status`
- [Statusless books re-pin top (#4677)](sync-statusless-book-rebump-4677.md) — `(a??null)!==(b??null)`
- [Pull cursor via synced_at (#4678)](sync-synced-at-cursor-4678.md) — books `synced_at` + BEFORE trigger
- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — validate koreader JSON, not any 2xx
- [koplugin bulk download (#4751)](koplugin-bulk-download-4751.md) — `downloadAll()`; `listCloudOnlyBooks()`; `coroutine.status`
- [WebDAV metadata sync (#4756)](webdav-metadata-sync-4756.md) — PR #4776; LWW on `book.updatedAt` + `updateBookMetadata`
- [File-sync refactor (#4784)](webdav-filesync-refactor-plan.md) — `FileSyncEngine`/`FileSyncProvider`/`merge.ts`; incremental + Full Sync
- [WebDAV connect nullified (#4780)](webdav-connect-nullified-4780.md) — catch+finally saved STALE closure; `getState().settings`
- [Google Drive provider research](gdrive-sync-provider-research.md) — Drive as `FileSyncProvider`; token persist + resumable upload
- [Google Drive provider multi-PR status](gdrive-provider-multipr-status.md) — PR1+PR2+PR3-so-far DONE on `feat/gdrive-sync-core` (5 commits thru `5efbe6b2f`, UNPUSHED, suite 6411 green); native KV + desktop OAuth runner + connect + ingress filter shipped; REMAINING (UI generalization + tauri.conf scheme + live verify) blocked on official Google client id (ops)
- [WebDAV credential sync (#4810)](webdav-credential-sync-4810.md) — `webdav.*` missing from `SETTINGS_WHITELIST`/`ENCRYPTED_FIELDS`; new nested whitelist group NEEDS matching `mergeSettings` deep-merge or shallow merge clobbers per-device siblings
- [Multi-window settings clobber (#4580)](multiwindow-settings-clobber-4580.md) — stale window overwrites shared settings.json; cross-window broadcast `settingsSync.ts`
- Google Drive: [research](gdrive-sync-provider-research.md) Drive as `FileSyncProvider` token-persist+resumable; [multi-PR status](gdrive-provider-multipr-status.md) PR1+2+3 DONE on `feat/gdrive-sync-core` (5 commits thru `5efbe6b2f`, UNPUSHED, suite 6411 green); native KV+desktop OAuth+connect+ingress shipped; REMAINING (UI generalization, tauri.conf scheme, live verify) blocked on official Google client id (ops)
- [Hardcover progress edition_id (#4792)](hardcover-progress-edition-id-4792.md) — `edition_id` fell back to `book_id`; resolve real edition; old bug via #4614
## Build, Testing & CI
- [format:check separate gate](verify-format-check-gate.md) — `pnpm format:check` own gate before push
@@ -72,6 +64,8 @@
- [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — un-awaited ranges OOM; MAX_CONCURRENT_RANGES=6
- [Android themed icon (#4733)](android-themed-icon-4733.md) — no monochrome → force-commit; tint=SRC_IN
## Reader Features & UI
- [PDF scrolled-mode lag (#4795/#4031)](pdf-scroll-lag-preload-4795.md) — 415ms/page render vs 50% margin; widen to 200% + `planScrollModePages` nearest-first bounded scheduler; CDP-verified Xiaomi
- [Scrolled-PDF pinch-zoom (#4817)](scrolled-pdf-pinch-zoom-4817.md) — live pinch + no-shift (gap×`--scroll-zoom` + rect-match anchor); cross-page pinch vs native selection MUTUALLY EXCLUSIVE (Android serializes cross-iframe touches), chose native selection (iframes interactive when idle); zoom store/attr desync gotcha
- [Search modes #4560 + spoiler-bound bug](search-modes-4560-and-spoiler-bound-bug.md) — regex + nearby-words; lookupPassage spoiler bound (page vs chunk)
- [OPDS groups carousel (#4750)](opds-groups-carousel-4750.md) — >=2 groups → virtuoso carousel; `scrollToIndex`
- [WebDAV browser sort + search (#4724)](webdav-browse-sort-search-4724.md) — PR #4786; `sortWebDAVEntries`/`filterWebDAVEntries`
@@ -87,54 +81,47 @@
- [Tap to open image/table (#4600)](tap-to-open-image-table-4600.md) — `iframe-open-media` + `detectMediaTarget`
- [PDF/CBZ Contrast view-menu](pdf-cbz-contrast-view-menu.md) — per-book `contrast`; ONE `filter:` (invert+contrast); `skipGlobal=true`
- [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — once-per-gesture latch; `isLongPressHold` 300ms
- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) `--dict-font-scale`; MDict `::part(dict-content)`
- [Dictionary lemmatization (#4574)](dict-lemmatization-4574.md) — inflected→lemma; `-ses→-sis` before `-es`
- [Word Lens inline gloss](wordlens-feature.md) — CFI-safe `<ruby cfi-skip>…<rt cfi-inert>`; TTS/search isolation
- [Word Lens en-en](wordlens-en-en.md) — simplest WordNet synonym; same-lang manifest-driven
- Dict: [popup font size #4443](dict-popup-font-size-4443.md) `--dict-font-scale` MDict `::part(dict-content)`; [lemmatization #4574](dict-lemmatization-4574.md) inflected→lemma `-ses→-sis` before `-es`
- Word Lens: [inline gloss](wordlens-feature.md) CFI-safe `<ruby cfi-skip>…<rt cfi-inert>` TTS/search isolation; [en-en](wordlens-en-en.md) WordNet synonym manifest-driven
- [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` = MAX over active subs
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) — MediaStore; sharekit 0-byte self-copy (Temp==cacheDir)
- [Webtoon Mode (#3647)](webtoon-mode-3647.md) — no-gap scrolled images; FXL fit-width; `--scroll-page-gap`
- [Biometric app-lock (#4645)](biometric-app-lock-4645.md) — flag from `appLockStore`; plugin `cfg(mobile)`
- [Reference Pages (#4542)](reference-pages-672-4542.md) — 'reference' progressStyle; `referencePageCount`
- [E-ink screen refresh page-turner (#4687)](eink-screen-refresh-pageturner-4687.md) — bindable 'refresh' action; generic `EinkRefreshController.kt` reflection (BOOX/Tolino/Rockchip), NO Onyx SDK bundle; gate Android+`viewSettings.isEink`
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md) — Share gated mobile+macOS; `annotationToolbarItems`
- [Instant highlight delete orphan (#4773)](instant-highlight-delete-orphan-4773.md) — stale memoized index + in-place `deletedAt`; re-check at READ
- [Empty highlight leak on annotate cancel (#4791)](empty-highlight-leak-on-annotate-cancel-4791.md) — Annotate eagerly creates placeholder; track `notebookNewHighlightId`, presentation-driven cleanup effect
- [Customize Toolbar global (#4760)](customize-toolbar-global-serializeconfig.md) — `serializeConfig` ref-compare → stale override; value compare
- [Native iOS TTS (#4676)](native-ios-tts-4676.md) AVSpeechSynthesizer; pause==stop; rate `pow^(1/2.5)`
- [Native TTS offline halt (#4613)](native-tts-offline-autoadvance-4613.md) — advance only on `end`; SKIP-on-error via `forward()`
- [Edge TTS word highlight (#4017)](edge-tts-word-highlighting-4017.md) — `audio.metadata` WordBoundary by rAF; gate on UA
- [Edge TTS word-highlight drift](tts-word-highlight-singletextnode-drift.md) — TEXT_NODE fast path ignored offsets; slice `[start,end]`
- Native TTS: [iOS #4676](native-ios-tts-4676.md) AVSpeechSynthesizer pause==stop rate `pow^(1/2.5)`; [offline halt #4613](native-tts-offline-autoadvance-4613.md) advance only on `end`, SKIP-on-error via `forward()`
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md) `audio.metadata` WordBoundary by rAF gate on UA; [drift](tts-word-highlight-singletextnode-drift.md) TEXT_NODE fast path ignored offsets, slice `[start,end]`
- [TTS highlight granularity setting](tts-highlight-granularity-setting.md) — Word/Sentence; gate `prepareSpeakWords` on granularity (NOT supportsWordBoundaries); suppress = wordBoundaries && word
- [TTS start-from-selection](tts-start-from-selection.md) — use last mark at/before sel; cloneRange+deselect
- [Reuse TTS session on mode entry](tts-reuse-session-mode-entry.md) — `redispatchPosition()` + `tts-sync-request` replay
- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) #4585 fixed; stale #4589 reverted it
- [RSVP font face/family (#4519)](rsvp-font-settings-4519.md) — `getBaseFontFamily`; overlay in top doc
- [RSVP RTL word display (#4630)](rsvp-rtl-word-display-4630.md) — ORP breaks Arabic; `isRTLText` → whole `dir=rtl`
- RSVP: [control-bar overlap REVERT](rsvp-control-bar-overlap-revert.md) #4585 fixed/#4589 stale-reverted; [font face/family #4519](rsvp-font-settings-4519.md) `getBaseFontFamily` overlay top doc; [RTL word #4630](rsvp-rtl-word-display-4630.md) ORP breaks Arabic→whole `dir=rtl`
- [Overlay z-index scale](zindex-overlay-scale.md) — RSVP 100 / Settings 110 / ModalPortal 120 / toast 130 / app-lock 200
- [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — `global` re-fanned every turn; `WeakMap<Document>` memo
- [Overlayer splitRange text nodes](overlayer-splitrange-textnodes.md) — `'p,h1-h4'` dropped `li`; walk text nodes + img/svg
- [Android image callout freeze](android-image-callout-freeze.md) — `.no-context-menu` on ANCESTOR
- [Table dark-mode tint (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` tint gated on `overrideColor`
- [Footnote aside border line (#4438)](footnote-aside-namespace-order-4438.md) — @font-face before @namespace; hoist @namespace
- [Proofread enhancements (#4700)](proofread-enhancements-4700.md) regex UI; Ctrl+P reuse; `wholeWord` no-op
- [Proofread per-book CRDT sync (#4781)](proofread-per-book-crdt-sync.md) — `mergeProofreadRules` by id; `ensureRuleId` backfill
- Proofread: [enhancements #4700](proofread-enhancements-4700.md) regex UI/Ctrl+P reuse/`wholeWord` no-op; [per-book CRDT #4781](proofread-per-book-crdt-sync.md) `mergeProofreadRules` by id, `ensureRuleId` backfill
- [Russian hanging-preposition NBSP (#4769)](russian-hanging-prepositions-nbsp-4769.md) — generic `nbsp` + `NBSP_LANGUAGES` (`ru`); after `whitespace`/`simplecc`
- [OPDS Firefox strict-XML (#4479)](opds-firefox-strict-xml-4479.md) junk after `</feed>`; `parseOPDSXML` slices to last close tag
- [OPDS2 JSON search greyed (#4502)](opds2-json-search-4502.md) — expand `{?query}` BEFORE resolveURL
- [OPDS HTML description (#4503)](opds-html-description-4503.md) — double-escaped; decode-once + sanitize
- [OPDS self-link metadata (#4749)](opds-self-link-metadata-4749.md) — `rel:self` deref; `getOPDSDescriptionHtml`
- [OPDS popular catalog dedup (#4782)](opds-popular-catalog-dedup-4782.md) — `getUnaddedPopularCatalogs` normalized-URL
- OPDS: [Firefox strict-XML #4479](opds-firefox-strict-xml-4479.md) junk after `</feed>` slice to last close; [JSON search #4502](opds2-json-search-4502.md) expand `{?query}` BEFORE resolveURL; [HTML desc #4503](opds-html-description-4503.md) decode-once+sanitize; [self-link meta #4749](opds-self-link-metadata-4749.md) `rel:self` deref; [popular dedup #4782](opds-popular-catalog-dedup-4782.md) normalized-URL
- [D-pad Navigation](dpad-navigation.md) — Android TV remote / arrow-key nav
- [koplugin cover upload (#4374)](koplugin-cover-upload.md) — `extractLocalCover` via `getCoverImage`
## Library Fixes
- [Book action platform surfaces](book-actions-platform-surfaces.md) — context menu Tauri-desktop-only; cross-platform in `BookDetailView`
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — single `await Menu.new({ items })`
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) `parseLabeledAuthor` + `isPlausibleAuthorName`
- [TXT chapter measure-word FP (#4658)](txt-chapter-measure-word-4658.md) — strong `[章节回讲篇话]` vs weak `[卷本册部封]`
- TXT: [author recognition #4390](txt-author-recognition-4390.md) `parseLabeledAuthor`+`isPlausibleAuthorName`; [chapter measure-word FP #4658](txt-chapter-measure-word-4658.md) strong `[章节回讲篇话]` vs weak `[卷本册部封]`
- [Cover stale (in-place mutation)](cover-stale-inplace-mutation-memo.md) — pure `getBookWithUpdatedMetadata`
- [Series/author back no-op (#4437)](series-folder-back-noop-4437.md) — Next 16.2 empty-search no-op; `handleBack` `group=''`
- [Library/reader separate texture (#4743)](library-reader-separate-texture-4743.md) — `libraryBackground*` device-local; `none` unmounts
- [List view series overflow (#4796)](list-view-series-overflow-4796.md) — fixed `h-28` clipped series+description (Android font scale); `min-h-28`
- [Recently-read shelf (#3797)](recent-read-shelf-3797.md) — PR #4829; reuse `BookItem` + shared `useOpenBook`; flex strip needs grid's gap-aware `flexBasis` calc + `min-w-0` to align with bookshelf columns
## Architecture & Patterns
- foliate-js submodule at `packages/foliate-js/`; multiview paginator preloads adjacent sections
- [Markdown .md support (#774)](markdown-md-support-774.md) — in-memory foliate book (no EPUB) in `src/utils/md.ts`; split-at-H1; `isMd()` before `isTxt()`; nav contract: `section.id``splitTOCHref` SAME type (string), `loadText` needed for fragment CFIs, `XMLSerializer` for void-tag XHTML
- Style: `getLayoutStyles()` always, `getColorStyles()` when overriding color; `transformStylesheet()` rewrites EPUB CSS at load
- TTS independent section tracking (`#ttsSectionIndex`); safe insets: native plugin → useSafeAreaInsets → styles; Dropdowns `DropdownContext`
- Stale settings closure: store-hook `settings` stale across `await`; persist `useSettingsStore.getState().settings` ([#4780](webdav-connect-nullified-4780.md))
@@ -152,5 +139,6 @@
- [No test seams in prod](feedback_no_test_seams_in_prod.md) — prod never imports `__reset*ForTests`
- [No lookbehind regex](feedback_no_lookbehind_regex.md) — never `(?<=)`/`(?<!)`; build check rejects
- [en plurals manual](feedback_en_plurals_manual.md) — only plural variants + proper nouns; `_one`/`_other`
- [i18n:extract prunes keys](i18n-extract-prunes-keys.md) — `removeUnusedKeys` deletes valid keys; revert churn, add new keys manually
- [Dependabot transitive fixes](dependabot-pnpm-overrides.md) — pin in `pnpm-workspace.yaml` `overrides:`; alert#≠issue#
- [Upgrade gstack locally](feedback_gstack_upgrade.md) — upgrade from project `.claude/skills/gstack`
@@ -0,0 +1,30 @@
---
name: eink-screen-refresh-pageturner-4687
description: "Page-turner \"Refresh Page\" action that deep-refreshes the e-ink panel (clear ghosting) on Android, via generic reflection across BOOX/Tolino/Rockchip"
metadata:
node_type: memory
type: project
originSessionId: 742b1517-392b-4735-8355-32b57fbfa400
---
Issue #4687 — added a bindable **"Refresh Page"** page-turner action that triggers a deep e-ink full refresh (GC16) to clear ghosting. Shipped as **PR #4822 (MERGED)** (`feat/eink-screen-refresh-pageturner` → main, 55 files +470/-41), built in an isolated worktree off origin/main (worktree + branch since removed). Rebase note: origin/main's Drive-sync PR #4821 added `secure_item` native-bridge commands at the exact anchors I used (end of COMMANDS / handler list / structs / impls), so all 7 plugin files (build.rs, default.toml, commands.rs, desktop.rs, lib.rs, mobile.rs, models.rs) conflicted on apply — resolved "keep both" by re-adding `refresh_eink_screen` after the secure_item code; locales re-derived via script on main's current files; autogenerated permission files regenerated via `cargo check -p tauri-plugin-native-bridge`.
**Frontend** (reuses the existing hardware page-turner binding machinery — see [[keyboard-selection-adjust-4728]] / `src/utils/keybinding.ts`):
- `keybinding.ts`: `'refresh'` added to `PageTurnAction` + `PAGE_TURN_ACTIONS` (so `resolvePageTurn` matches it). `matchesBinding` now accepts `undefined`.
- `types/settings.ts`: `HardwarePageTurnerSettings.bindings.refresh?: KeyBinding | null` — OPTIONAL (older persisted settings lack it; never migrate, optional-chaining handles absence). Default `refresh: null` in `constants.ts`.
- `PageTurnerSettings.tsx`: refresh slot rendered ONLY when `appService?.isAndroidApp && viewSettings.isEink` (the user-facing Eink-mode view setting, not just hardware detection).
- `usePagination.ts` `handleHardwarePageTurn`: branch `if (action === 'refresh') { if (appService?.isAndroidApp) refreshEinkScreen().catch(()=>{}); return true; }` BEFORE the page/section side/mode logic. Also added `bindings.refresh?.source === 'native'` to `hasNativeBinding` + the effect dep array so a media key bound to refresh still acquires page-turner key interception.
- `bridge.ts`: `refreshEinkScreen()``invoke('plugin:native-bridge|refresh_eink_screen')`.
**Native generic refresh** (`EinkRefreshController.kt`, new) — the answer to "compatible with most e-ink devices, generic interface not brand SDK". Android has NO public e-ink API; each vendor patches `android.view.View`. Probe via reflection, stop at first success (patterns from KOReader android-luajit-launcher EPD controllers):
1. **Onyx BOOX (Qualcomm)**: `View.refreshScreen(0,0,w,h, 34)` instance method. `34 = FULL(32)+GC16(2)`.
2. **Tolino/Nook (NTX/Freescale)**: `View.postInvalidateDelayed(0L,0,0,w,h, 34)`.
3. **Rockchip (Boyue clones)**: `View.requestEpdMode(View$EINK_MODE.EPD_FULL, true)`.
Deliberately do NOT bundle the Onyx SDK (`com.onyx.android.sdk.*` classes aren't on-device unless bundled — reflection would always fail) and do NOT call Onyx `setWaveformAndScheme`/None (KOReader does, but it owns the update loop; Readest leaves system auto-update in place, so switching to manual mode could FREEZE later updates). Run on UI thread against `activity.window.decorView`; `success:false` (no controller) is a soft no-op, not an error. iOS Swift stub resolves `{success:false}`.
**Plugin wiring** added across `models.rs`/`commands.rs`/`mobile.rs`/`desktop.rs`/`lib.rs` + `build.rs` COMMANDS + `permissions/default.toml` `allow-refresh-eink-screen` (build regenerates `reference.md`/`schema.json`/`commands/refresh_eink_screen.toml`). App uses `native-bridge:default` so no capability edit needed.
**Verified on real hardware**: ONYX BOOX Leaf5 (`ro.product.manufacturer=ONYX`). `pnpm dev-android` build+install; via adb+CDP invoked `plugin:native-bridge|refresh_eink_screen` directly in the WebView → `{success:true}`, logcat `EinkRefresh: onyx full refresh requested` (the Onyx/Qualcomm `View.refreshScreen` path, decor view), and the user visually confirmed 5/5 full GC16 screen flashes in the reader. So the onyx path works on modern BOOX without SDK bundling or `setWaveformAndScheme` priming. (CDP socket is pid-bound `webview_devtools_remote_<pid>`; re-forward when the WebView process recycles — see [[cdp-android-webview-profiling]].)
**i18n**: ran into [[i18n-extract-prunes-keys]] (scanner `removeUnusedKeys:true` deleted ~314 dynamic keys / huge churn). REVERTED the scanner output and added the single `"Refresh Page"` key MANUALLY to all 33 non-en locales (en is key-as-content, needs no entry), aligning each translation with the locale's existing `"Reload Page"`/`"Next Page"` terminology. `check:translations` green.
@@ -0,0 +1,46 @@
---
name: empty-highlight-leak-on-annotate-cancel-4791
description: Annotate eagerly creates a highlight placeholder; cancelling the note must tear it down
metadata:
node_type: memory
type: project
originSessionId: 1c75c865-8e1b-4641-ac20-81692d3ff20b
---
#4791 — clicking **Annotate** on a selection eagerly creates a highlight (`note:''`)
as the note anchor (`handleAnnotate``handleHighlight(true)` in `Annotator.tsx`),
so the selection stays visible while the NoteEditor is open. Cancelling the note
(Cancel button, overlay, Escape, switching books, closing the notebook) left that
empty highlight leaked into config → showed as a stale card in the left-sidebar
Booknotes list + a phantom yellow highlight.
**Fix:**
- `handleHighlight` now returns the created `BookNote` only when it pushes a NEW
record (returns `null` when it restyles/toggles an EXISTING one — that record
predates the flow and must survive a cancel).
- `handleAnnotate` stores `created?.id` via `setNotebookNewHighlightId` (new
`notebookStore` field). This tracked id is what distinguishes a removable
placeholder from a pre-existing highlight; do NOT identify it by cfi (a fresh
selection can collide with an existing highlight's cfi).
- `removeEmptyAnnotationPlaceholder(booknotes, id, now)` in `annotatorUtil.ts`
tombstones (`deletedAt`) the live annotation with that id ONLY if it still has
no note text, and returns it so the caller tears the overlay down with
`removeBookNoteOverlays` across ALL views (`getViewsById`, symmetric with how
`handleHighlight` drew it).
- Cleanup is **presentation-driven**, not threaded through every cancel path:
`Notebook.tsx` runs `handleCancelNewAnnotation` from an effect whenever the
creation editor stops being presented (`!(isNotebookVisible && notebookNewAnnotation)`)
— catches Cancel/Escape/overlay/close/swipe/navigate — plus a second effect's
cleanup on `sideBarBookKey` change / unmount for book-switch (pinned) and
reader-close.
- Save survives the guard (placeholder gains note text) and also clears the
tracked id. `handleCancelNewAnnotation` has stable identity (empty deps) so the
effects don't re-fire mid-edit; it reads settings fresh via
`useSettingsStore.getState().settings` (stale-closure guard, see [[webdav-connect-nullified-4780]]).
**Why id-set-LAST in handleAnnotate matters:** `setNotebookNewHighlightId` is
called after `setNotebookVisible(true)` + `setNotebookNewAnnotation`, so no
intermediate render has (editing=false AND a fresh placeholder id) — prevents the
presentation effect from deleting the placeholder it just created.
Related: [[instant-highlight-delete-orphan-4773]], [[customize-toolbar-global-serializeconfig]].
@@ -38,6 +38,34 @@ Adding **Google Drive as a second `FileSyncProvider`** for the merged file-sync
**Drive UI DONE (commit `c657c34f0`):** `FileSyncForm` (shared sync controls extracted from WebDAVForm, parameterized by kind, builds provider via registry; WebDAVForm refactored to use it, behavior unchanged) + `GoogleDriveForm` (OAuth Connect/account/Disconnect + FileSyncForm) + `googleDriveConnect.ts` (assembles env client id + keychain + desktop runner) + IntegrationsPanel "Google Drive" row gated on `appService.isDesktopApp`. Full suite 6412 green.
**Cloud Sync redesign DONE (commit `1a31a8cbd`):** new "Third-party Cloud Sync" Integrations section with a unified "Cloud Sync" sub-page (`CloudSyncForm`) — WebDAV + Google Drive MUTUALLY EXCLUSIVE via `withActiveCloudProvider` (enabling one disables the other). Radio picker (AIPanel pattern) + shared `FileSyncForm`. WebDAVForm/GoogleDriveForm refactored to embeddable panels; Drive has a "configured-but-inactive" state (`accountLabel` present, `enabled=false`) with frictionless "Use Google Drive" re-activate (no re-OAuth); explicit Disconnect clears the keychain token. Temp concurrency probe removed (upload was already concurrency-4, confirmed).
**Reader auto-sync DONE (commit `f5e07e50b`):** `useWebDAVSync``useFileSync` — the reader auto-syncs the single ACTIVE provider per-book while reading (pull-on-open, debounced push, cover/file). Async engine build (Drive keychain probe) held in state, pull-on-open waits for it; engine keyed on connection-relevant settings (not lastSyncedAt) to avoid re-probing keychain; deviceId/lastSyncedAt write the active provider slice; events renamed `*-file-sync`. WebDAV reader behavior unchanged.
**Drive feature is functionally complete on desktop:** connect, manual Sync now, auto-sync while reading, exclusive provider switching. Live-verified: connected + synced a 675-book library.
**DESKTOP PR OPENED: readest/readest#4821** (`feat/gdrive-sync-core`, rebased onto origin/main, all gates green incl. rust). Covers provider + OAuth + native KV + redesign (exclusive Third-party Cloud Sync section, inline radio switch) + reader auto-sync + **premium gating** (any paid plan via `isCloudSyncInPlan`; free sees upgrade CTA; reader auto-sync off for free). Rebase needed `git -c protocol.file.allow=always submodule update --init packages/foliate-js` (foliate-js drift, index wanted `6f1a190`).
**PR #4821 review fix (pushed `5769682c5`):** CodeQL flagged `escapeDriveLiteral` (driveRest.ts) for not escaping backslashes — fixed (escape `\``\\` FIRST, then `'``\'`). Was the only review comment.
**Both branches REBASED onto origin/main `324bb8a36` (was `7e78f80e1`). UNPUSHED, both gates green (lint+format+full suite: mobile 6483, resumable 6486). foliate-js submodule drift on rebase: origin/main now wants `0fa407c4c` (not in local submodule clone whose origin is the main checkout's modules dir); fix `git -C packages/foliate-js fetch https://github.com/readest/foliate-js.git 0fa407c4c... && git -C packages/foliate-js checkout 0fa407c4c...` (the `submodule update --init` shortcut FAILS here — local origin lacks the commit; must fetch from GitHub URL). Current commits: mobile `6728c94f0`(Android)+`8b3dd1cd5`(iOS); resumable `f7a1e5117`.**
**Branch `feat/gdrive-mobile-oauth` (Android+iOS OAuth) — no longer stacked, off main. PR not opened yet.**
**Android OAuth (PR4) DONE (commit `eb8e22081`, was `5583c9b38` pre-rebase).** `auth/oauthAndroid.ts` (`runAndroidOAuth` via existing `authWithCustomTab`, DI, 2 tests) + platform dispatch in `googleDriveConnect` (`osType()==='android'`→Custom Tab, else desktop) + Drive row shown on Android. NATIVE (device-verify pending, no Android toolchain in CI): `NativeBridgePlugin.kt` `handleIntent` resolves `com.googleusercontent.apps.<id>:/oauthredirect` via the same `pendingInvoke` as the Supabase callback; matching BROWSABLE intent-filter added to `gen/android/.../AndroidManifest.xml`.
**iOS OAuth (PR5) DONE (commit `1230fb291`).** `auth/oauthIos.ts` (`runIosOAuth` via `authWithSafari({authUrl, callbackScheme})`; callbackScheme = `deriveReverseDnsRedirectScheme(clientId)` = bare `com.googleusercontent.apps.<id>` — ASWebAuthenticationSession matches on SCHEME not path; DI, 2 tests) + `AuthRequest.callbackScheme?` (nativeAuth.ts; Supabase keeps native `readest` default) + `resolveOAuthRunner` `os==='ios'`→runIos + Drive row on iOS (`isDesktopApp||isAndroidApp||isIOSApp`). `createDriveTokenPersistence` already works on iOS (Keychain via secure-KV). NATIVE (device-verify pending, no iOS toolchain in CI): Swift `auth_with_safari` uses `args.callbackScheme ?? "readest"` (`SafariAuthRequestArgs.callbackScheme: String?`); `Info-ios.plist` CFBundleURLTypes gains the reverse-DNS scheme. macOS Drive uses the desktop deep-link runner (NOT authWithSafari), so no macOS native change. Full suite 6477 green + lint + format + plutil OK.
**Drive streaming upload/download DONE — own branch `feat/gdrive-resumable-upload` off origin/main (commit `0c9cc1a22`, UNPUSHED).** `uploadStream`+`downloadStream` on GoogleDriveProvider so book files stream from/to disk instead of buffering the whole file in the JS heap (buffered marshal of a large book across the WebView↔Rust bridge crashes the renderer on mobile — this unlocks Drive book sync on Android/iOS and flattens heap on desktop too). `driveRest.resumableCreateUrl`/`resumableUpdateUrl`; `uploadStream` opens a Drive resumable session (POST new `{name,parents}` / PATCH existing `{name}`, metadata in initiation so NO reparent follow-up), reads `Location` session URI, PUTs bytes via `tauriUpload`; `downloadStream` GETs `alt=media` to disk via `tauriDownload` + bearer. Attached **Tauri-only** (`isTauriAppPlatform()`); web keeps buffered fallback. Both swallow→`false` per provider contract (engine retries once). REUSES `@tauri-apps/plugin-upload` already shipped for WebDAV — NO new native code. Single-shot streaming PUT (not chunked mid-stream resume) — sufficient for the heap/OOM fix; chunked-resume-on-failure is a further enhancement. Full suite 6484 green + lint + format. **NOTE: changes desktop Drive book sync from buffered → streaming (previously live-verified buffered); device-verify the streaming path on desktop + mobile.**
**ALL PRs MERGED to main/dev (dev @ `c6f2a83d9`).** Worktree `feat/gdrive-*` branches no longer needed; work continues in the MAIN repo `/Users/chrox/dev/readest` on `dev` (tracks `origin/main`; there is NO `origin/dev`).
- **#4821** desktop Drive cloud sync + premium Third-party Cloud Sync section.
- **#4824** Drive resumable streaming upload/download.
- **#4823** mobile OAuth (Android Custom Tab + iOS ASWebAuthenticationSession).
- **#4827** Android sync fix: retry THROWN transport errors in `withBackoff` (was 429/5xx only); `mapDriveError` classifies transport throws (incl. Tauri plugin's plain `error sending request` Error) as NETWORK. Root cause: Android pooled keep-alive connection to googleapis.com goes stale mid-sync → every files.list after the first batch threw; sync recovered on its own after ~3-4 min (reqwest evicting dead conns). The retry forces a fresh connection so recovery is fast + kills the error spam.
**CODE COMPLETE + MERGED.** REMAINING (human/ops-only): (1) on-device re-verify with #4827 in the build — Android sync should no longer stall ~3-4 min / spam `failed to inspect hash dir`; iOS OAuth sign-in; desktop streaming book-sync re-check; (2) Google consent screen → Production (testing caps 100 users). NOTE: Android build auto-generates a deep-link intent-filter for the gdrive reverse-DNS scheme in `gen/android/.../AndroidManifest.xml` (duplicates the manual `gdrive-oauth` filter) — benign build drift, don't commit.
**PR3 REMAINING:**
- **LIVE VERIFICATION (needs the user — real Google sign-in):** `pnpm tauri dev` → add own Google account as a Test user in the consent screen (Testing mode caps + gates) → Settings → Integrations → Google Drive → Connect → browser → grant → "Connected as <email>" → add book / Sync now → confirm `Readest/books/<hash>/{config.json,cover.png}` in Drive. Windows cold-browser fallback.
- **Reader-hook auto-sync (deferred):** generalize `useWebDAVSync``useFileSync` (per-provider state maps, async Drive provider build in the hook) so Drive auto-syncs per-book while reading like WebDAV. Manual Sync-now already works without it; do after live-verifying the base.
@@ -0,0 +1,36 @@
---
name: i18n-extract-prunes-keys
description: "pnpm i18n:extract (removeUnusedKeys) deletes valid keys not statically in the branch; don't commit that churn"
metadata:
node_type: memory
type: feedback
originSessionId: afe50e44-d394-4301-bd81-1368df66f90b
---
`pnpm run i18n:extract` (i18next-scanner, `i18next-scanner.config.cjs` has
`removeUnusedKeys: true`) can DELETE ~30+ valid-looking keys from every non-`en`
locale on a feature branch — keys whose source usage isn't statically present in
the current branch (e.g. `"Sync History"`, `"downloaded {{n}} book(s)"`,
`"Match Whole Words"`). The extract diff then shows huge churn (~1000 +/- lines)
unrelated to your change.
**Why:** the committed locales can be ahead of the branch's source (strings from
features not yet on this base, or built dynamically/in non-scanned modules), and
`removeUnusedKeys` strips anything the scanner can't find. `en/translation.json`
is a tiny key-as-content file (~70 lines, only plural/proper-noun overrides), so
new keys never land there anyway — it stays out of the diff.
**How to apply:** for a feature that adds a few strings, do NOT commit the
scanner's deletions into an unrelated PR.
1. Run `pnpm run i18n:extract` (optional — only confirms which keys are new).
2. `git checkout -- apps/readest-app/public/locales` to drop ALL the churn.
3. Add ONLY your new keys manually to each locale in `i18n-langs.json` with real
translations. The files are exactly `JSON.stringify(obj, null, 2) + "\n"`, so
a Node script that `JSON.parse`s, appends new keys (insertion order preserved),
and rewrites that way yields a zero-extra-diff result. Skip `en` (key-as-content).
Match each locale's existing terminology (grep the file for a related key, e.g.
`"Export Annotations"` / `"Annotations"`, before translating). Verify with
`grep -rn '"<Your Key>"' apps/readest-app/public/locales | wc -l` == number of locales.
Related: [[feedback_en_plurals_manual]].
@@ -0,0 +1,40 @@
---
name: markdown-md-support-774
description: Markdown (.md) reading via in-memory foliate book (no EPUB); split-at-H1; foliate book-object contract gotchas
metadata:
node_type: memory
type: project
originSessionId: a82e979b-0edb-4964-91fd-3677ecfe5679
---
Issue #774: render standalone `.md` files at runtime (NO EPUB conversion). **MERGED as
PR #4816** (branch `feat/markdown-support`). Built test-first via `/autoplan` (CEO+Eng
dual-voice review). Suite green (6365), lint + format clean; live-verified in web app
(import via drop, split TOC, cross-section nav, GFM rendering, pagination).
**Where:** `src/utils/md.ts` `makeMarkdownBook(file)` builds an in-memory foliate book
modeled on `packages/foliate-js/fb2.js`. Routed in `src/libs/document.ts` `open()` via
`isMd()` **before `isTxt()`** (a `.md` served as `text/plain` would otherwise hit TXT→EPUB).
`'md'` added to `SUPPORTED_BOOK_EXTS` (constants.ts). `sanitize.ts` `sanitizeHtml` gained
`'class'` (code `language-*` theming) + `del`/`ins` tags. Pipeline: strip YAML frontmatter →
`marked`(gfm) → `sanitizeHtml` → split at `<h1>` (preamble = pre-first-H1 content) → nested
heading-outline TOC.
**Non-obvious foliate book-object contract (cost us the CRITICAL review finding):**
- `section.id` and `splitTOCHref()` output MUST be the SAME type. readest nav
(`services/nav/index.ts:133`) does `new Map(sections.map(s=>[s.id,s]))` then `.get(sectionId)`
where sectionId = `splitTOCHref(href)[0]`. `SectionItem.id` is typed `string`, so use
STRING ids + `splitTOCHref => href.split('#')`. fb2.js uses numbers consistently (works
only because it's untyped JS); do NOT copy fb2's `Number(x)`.
- Fragment CFIs / TOC sub-anchors require `section.loadText` (nav skips sections without it,
index.ts:153). Provide it.
- `SectionItem.cfi` is non-optional → set `cfi: ''` (foliate falls back to `CFI.fake.fromIndex`).
- `createDocument()` parses `application/xhtml+xml`; marked's HTML5 void tags (`<br><hr><img>`)
are parse errors there → serialize sections with `XMLSerializer` (not innerHTML). `load()`
and `createDocument()` must derive from the SAME string (CFI round-trip).
- `resolveHref` returns `null` for unresolved anchors (never index 0). jsdom lacks
`URL.createObjectURL``load()` is lazy + tests stub it.
**Deferred follow-ups (open issues):** relative image resolution (web File-objects have no
sibling access — needs the bundle model); Markdown folder/zip "package" model; footnotes/math/
Mermaid/wikilinks; syntax-highlight token colors. Plan: `.claude/plans/2026-06-26-markdown-md-support-774.md`.
@@ -0,0 +1,16 @@
---
name: multiwindow-settings-clobber-4580
description: Pagination/global settings revert with multiple desktop windows; cross-window broadcast fix
metadata:
node_type: memory
type: project
originSessionId: 4df1808d-e106-4316-9206-b4e606b4b9bf
---
Issue #4580 (fix: PR #4803, branch `fix/multiwindow-settings-revert-4580`): on desktop (Tauri) global view settings (Click/Swipe to Paginate, Show Page Navigation Buttons) "revert to default" — only when multiple windows are open (OP ran `1 + n_opened_books` windows).
**Root cause:** each Tauri window keeps its own in-memory `useSettingsStore.settings`, loaded once at window open. Global settings persist to ONE shared `settings.json`, and every window writes the WHOLE object via the store's `saveSettings`. A window opened before the user customized a global setting holds the default (e.g. `disableClick=false`); when it later saves (notably `handleCloseBooks` on reader-window close in `ReaderContent.tsx`, but ANY settings write) it clobbers the user's value back to default. Explains "reverts to *default*, only with multiple windows". Note: `replicaCursorStore` avoids this by load-modify-saving from disk each time.
**Fix:** cross-window broadcast. `src/utils/settingsSync.ts` (`broadcastGlobalSettings` emits `global-settings-window-sync` with `sourceLabel` + the two global blobs; `subscribeSettingsSync` ignores self; `mergeSyncedGlobalSettings` adopts `globalViewSettings`/`globalReadSettings` and preserves all device/window-local fields). Store `saveSettings` calls `broadcastGlobalSettings` after persisting. `useSettingsSync` (mounted in `Providers.tsx`, the shared root for both library + reader windows) adopts broadcasts via `setSettings`. No-op off Tauri.
Only the two global objects are synced (minimal scope) — covers the reported bug + sibling read settings; top-level scalars left window-local. No save/broadcast loop: receive calls `setSettings` only; the replica publisher subscriber pushes to network (no disk write) and pagination fields aren't in `SETTINGS_WHITELIST` anyway. Live cross-window view update of already-open books is intentionally NOT done (bug is persistence, not live propagation). Related: [[webdav-connect-nullified-4780]] (stale settings closure), [[window-state-sanitize-4398]].
@@ -0,0 +1,54 @@
---
name: pageturn-bg-replace-reflow-4785
description: "Page-turn frame drops at chapter boundaries (#4785) — per-frame"
metadata:
node_type: memory
type: project
originSessionId: e42ea03e-cda7-4e59-b398-1a28f589b37e
---
Issue #4785: swipe page-turn animation drops frames, worst crossing .xhtml
section boundaries, "first open" (Android/Xiaomi). Repro book is a Taiwan light
novel with custom fonts + `.bg`/`background-attachment:fixed` front-matter.
Root cause (in `packages/foliate-js/paginator.js`, a submodule):
`#replaceBackground()` rebuilt its whole paint context **every frame** of both
swipe phases — `getComputedStyle(<html>)` + `this.size` + one
`getBoundingClientRect()` **per rendered view** + a per-view background-reset
write loop + full `#background` DOM rebuild. Those forced reads scale with the
number of loaded views, which **peaks at a chapter boundary** because adjacent
sections are preloaded there — hence "worst at boundaries". Two callers ran it
per-frame: the snap `syncBackground` rAF loop (`#scrollTo`) and the drag-phase
container `scroll` listener (`#onTouchMove``scrollBy`→scroll event).
Everything `#replaceBackground` reads is **invariant for one gesture** (theme/
texture, bg+container geometry, each view size+bg) — only scroll offset changes.
Fix:
- Split into `#readBackgroundStyle` / `#computePaginatedBgContext` (the reads) +
`#paintPaginatedBackground(ctx, atPosition)` (writes only; calls the unchanged
pure `computeBackgroundSegments`).
- `#bgAnimContext` field snapshots context once: set in `#onTouchStart` (drag)
and at the start of the animated branch in `#scrollTo` (snap); cleared in
`#onTouchEnd` and both animation `.then()`s. `#replaceBackground` uses
`this.#bgAnimContext ?? this.#computePaginatedBgContext()`.
- Also deferred the heavy mid-drag forward preload: added `&& !this.#touchScrolled`
to the scroll-listener `#loadAdjacentSection` gate (columnize/expand on the main
thread janked the drag). The scroll that settles the gesture re-fires the gate
with the finger up, so the buffer still tops up.
Tests: `src/__tests__/document/paginator-background-anim-perf.browser.test.ts`
(real Chromium) drives `next()` (snap) and a synthetic touch drag, spying on the
primary iframe `<html>` getComputedStyle. Pre-fix: 39 reads (snap) / 7 (drag);
post-fix ≤3 / ≤1. Existing `paginator-background-segments.test.ts` (pure
`computeBackgroundSegments`) stays green — visual output unchanged.
Scrolled-mode branch kept inline in `#replaceBackground` (never the per-frame hot
path). Behavior preserved: scrolled set every view bg so the old reset loop was
redundant; `containerSize = containerRect[sideProp]` == old `this.size`.
NOT the cause (ruled out): `computeBookNav`/`nav.json` is awaited before the view
renders, so first/second-open in-memory state is identical — it can't explain
reading-time swipe jank. See [[booknote-view-autoscroll-4352]] neighbors in
Paginator & Scroll. Related: [[paginator-swipe-bg-flash]],
[[global-annotation-pageturn-perf-4575]], [[paginated-texture-occlusion-4399]].
@@ -0,0 +1,27 @@
---
name: pdf-scroll-lag-preload-4795
description: "PDF scrolled-mode rendering lag on Android (#4795/#4031) — fix via widened preload margin + bounded prioritized load scheduler in fixed-layout.js"
metadata:
node_type: memory
type: project
originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9
---
PDF **scrolled mode** showed blank pages while scrolling on Android (#4795, resurfacing #4031). Reproduced + fixed + CDP-verified on Xiaomi 13 (fuxi).
**Root cause (measured via CDP on-device):** per-page render (`pdf.js` `onZoom``render()`: canvas raster + text layer + annotation layer) ≈ **415 ms** uncached / ~65 ms cached, but the scrolled-mode IntersectionObserver used `rootMargin: '50% 0px'` (~0.5 page of lead). Loads also had **unbounded concurrency** (observer fired `#loadScrollPage` for every intersecting page) and **no viewport prioritization**, so the slow render never finished before the page scrolled into view, and a fling spawned dozens of competing renders. Per-page canvas ≈ **7 MB** at dpr 3 (screen-res, NOT the ~50 MB I first feared) → memory headroom existed; the #3470 OOM was byte-range *parsing* flood (`MAX_CONCURRENT_RANGES`, orthogonal).
**Fix** (`packages/foliate-js/`):
- `fixed-layout.js`: widened observer to `rootMargin: '200% 0px'` (~2 viewports lead); observer now only **flags `page.visible`** and calls new `#scheduleScrollPages()`.
- New pure exported `planScrollModePages({pages, currentIndex, maxLoaded, maxConcurrent, loadingCount})``{load, evict}`: loads **visible+idle pages nearest currentIndex first, bounded by `maxConcurrent - loadingCount`**; evicts **farthest non-visible loaded** beyond `maxLoaded`; **never evicts a visible page** (distance = `|index - currentIndex|`). Unit-tested in `src/__tests__/document/fixed-layout-scroll-scheduler.test.ts`.
- `#scrollMaxLoaded 8→12` (live-canvas cap = memory ceiling), `#scrollMaxConcurrent=3`, `#scrollLoadingCount` tracked in `#loadScrollPage` (inc on start, dec in `finally`, then reschedule so a freed slot pulls the next nearest page). Removed `#evictScrollPages` (scheduler handles it).
- **Terminal `error` state**: a load that throws or returns no src sets `state='error'` (not `'idle'`) so the post-completion reschedule can't retry a persistently failing page in a tight async loop (regression I introduced with reschedule-on-completion).
- `pdf.js`: `MAX_CACHED_PAGES 8→16` (page objects + render blobs are cheap, not the canvas) so back-scroll within the wider window doesn't re-parse.
**Verified (CDP + screenrecord, identical 9-swipe reading-pace test, fresh region):** baseline = mostly blank frames, settled forward lead **+2** (span [-9,+2]); fix = **every frame fully rendered**, forward lead **+4** (span [-7,+4]). Extreme 8-fling (240 pages/2s) still blanks mid-fling (inherent) but settles to rendered content and **no crash**.
**Best-practice cache strategy for scrolled PDF on mobile** (asked during this work): two bounded tiers — live-canvas cap = the hard memory ceiling (sized to window+lead), decoded-page cache a bit larger (cheap); distance/viewport-aware LRU never evicting visible; bound+prioritize loads; release bitmaps eagerly (`canvas.width=0`); the biggest unused lever for low-end devices is **capping effective DPR** (canvas mem ∝ DPR²) — not applied here since 12×7 MB≈84 MB is fine on the Xiaomi.
**CDP on release builds:** the installed Play/release 0.11.12 has **no `webview_devtools_remote_<pid>` socket** — WebView debugging is gated behind the `devtools` Cargo feature (`src-tauri/Cargo.toml`); must build+install `pnpm dev-android` (release + `--features devtools`, same keystore so it updates over the store build, library preserved). CDP `webSocketDebuggerUrl` comes back as `ws://localhost/devtools/...` **with no port** (echoes Host header) → rewrite to `ws://127.0.0.1:9222<path>`; `ws` npm pkg is CJS so import default + destructure. See [[cdp-android-webview-profiling]], [[pdf-oom-range-flood-3470]].
**WIP caveat:** during this work the foliate-js submodule had unrelated uncommitted `paginator.js` WIP (background-anim perf, #4785) — exclude it from any #4795 commit.
@@ -0,0 +1,16 @@
---
name: recent-read-shelf-3797
description: "Recently-read carousel at library top (#3797 / PR"
metadata:
node_type: memory
type: project
originSessionId: d5f79cf1-9e58-4ae4-9f8a-46a8e8ca625f
---
Opt-in "Recently read" strip in the library Virtuoso header (PR #4829, issue #3797). `selectRecentShelfBooks(books, count)` in `libraryUtils.ts` (filter `!deletedAt && progress != null`, sort by `updatedAt` desc, slice 12). Setting `libraryRecentShelfEnabled` (default false) + View-menu toggle. Rendered via the Virtuoso `Header` through `BookshelfListContext` (stable identity → no grid re-render churn); list `<Virtuoso>` needs explicit `context={listContext}`.
**Reuse, don't reimplement:** each slide renders the real `BookItem` (identical cover/title/progress/badges). The open path was extracted to `src/app/library/hooks/useOpenBook.ts` (in-place stale-record probe + `makeBookAvailable` on-demand download for cloud-only synced books + navigate) and is shared by `BookshelfItem` AND the recent shelf. Do NOT open via the select-mode `navigateToReader` path — it skips the download, so a recently-read book that synced (progress + `updatedAt`) without its blob fails to open on a second device.
**Alignment gotcha (cost several iterations):** a horizontal flex strip with `basis-1/N` does NOT match a CSS-grid column when the grid has a row gap — CSS Grid subtracts the gap from each track, flex `basis` does not (covers come out too wide at 2/3 cols where `BOOKSHELF_GRID_CLASSES` uses `gap-x-4`; matches at `sm+` where `gap-x-0`). Fix: size each slide with the grid's own formula `flexBasis: calc((100% - (var(--rs-cols) - 1) * var(--rs-gap)) / var(--rs-cols))`, with `--rs-cols` (responsive `3/4/6/8/12` ladder when auto, else `libraryColumns`) and `--rs-gap` (`1rem` base / `0px` sm+, mirroring `gap-x-4 sm:gap-x-0`) set on the row. Also `min-w-0` on each flex item, else image covers expand to intrinsic width. Verified 0.00-0.02px edge diff vs a real CSS grid at N=2/3/4/5 (standalone HTML repro + getBoundingClientRect).
Arrows: plain scroll div + `scrollBy`, shown on overflow (`scrollLeft`/`scrollWidth`, `ResizeObserver`), centered on `.bookitem-main` via measure; `start-2`/`end-2` + `rtl:rotate-180`. Swipe never opens (useLongPress moveThreshold). i18n: `i18n:extract` churns every locale (see [[i18n-extract-prunes-keys]]) — added the 2 keys manually; bo/si/ta/bn best-effort.
@@ -0,0 +1,25 @@
---
name: scrolled-pdf-pinch-zoom-4817
description: Scrolled-PDF live pinch-zoom + the cross-page-pinch vs native-selection tradeoff (readest
metadata:
node_type: memory
type: project
originSessionId: 902324ba-94ee-4c88-804e-ea9f796681f9
---
Live pinch-zoom for scrolled PDF (fixed-layout scrolled mode). The real fix is entirely in foliate-js: PR **readest/foliate-js#43 MERGED** to foliate main as `0fa407c` (on top of #42 `8bcb61e` which already had live pinch + the rect-match anchor + the interactive-when-idle idle-toggle). readest **PR #4817** is therefore **minimal — just the submodule bump to `0fa407c` + one unit test** (`fixed-layout-pinch-zoom.test.ts`, single clean commit `dd39837af`, +39/-1). readest needs NO touch-handling change: `origin/main`'s `useIframeEvents` already detects a two-finger gesture per page (`event.touches` forwarded via `iframeEventHandlers`) and calls `renderer.pinchZoom`. Builds on the scroll-lag scheduler [[pdf-scroll-lag-preload-4795]].
**Abandoned detour (do not re-add):** a host-level cross-page-pinch approach (`multiTouch.ts` `updateSourceTouches`/`flattenSourceTouches`, per-iframe `sourceIndex` binding, `allActiveTouches` reading `e.touches`, and a `usePagination` host-click tap fix) was built then fully reverted. It is unnecessary once cross-page pinch is dropped, and same-page pinch + centre-tap toggle both work through existing `origin/main` code (tap goes iframe -> `iframe-single-click` centre zone; the host-click path is never hit when iframes are interactive).
**Core architectural finding (the crux):** in scrolled FXL, **cross-page pinch and native text selection are mutually exclusive**. Each page is its own iframe; Android **serializes touches across iframe documents** (finger1 on page A gets `touchcancel` the instant finger2 lands on page B — proven via forwarded-touch logs), so a pinch spanning two pages can only be recognized if the *host* owns all touches, which requires `.scroll-page iframe { pointer-events: none }`. But inert iframes kill native selection/taps. So you pick one. User chose **native selection, drop cross-page pinch.**
**Final design (foliate `fixed-layout.js`):**
- `pinchZoom(ratio)` in scroll mode scales the whole `.scroll-container` live (`computeScrollPinchTransform`, transform-origin at viewport centre). `pinchEnd` snapshots the centre page's `getBoundingClientRect` and the commit re-render (`#renderScrollMode`) scrolls it back to that exact rect (`#restorePinchAnchor`) — no jump.
- **No-shift fix:** the inter-page gap must scale with zoom or the committed gaps don't match the transform-scaled preview. `margin: calc(var(--scroll-page-gap,4px) * var(--scroll-zoom,1))` and `#renderScrollMode` sets `--scroll-zoom = scaleFactor`. Verified preview->commit scale MATCH + position jump <=2px.
- Iframes interactive **when idle** (restored `#setScrollIframeInteraction(true)` in `#handleScrollEvent` settle; `#scrolling` flag + interactive-on-load in `#loadScrollPage` so selection works without scrolling first), inert only **during active scroll** (native-smooth). Same-page pinch flows through the per-iframe forwarded-touch path; pdf.js `setupPanningEvents` handles pan (empty-area drag scrolls host) + native selection (text drag). `overflow-x:auto` + `width:max-content` enable horizontal pan of a zoomed page.
**Gotcha — zoom store/attribute desync:** setting the `scale-factor` attribute directly (e.g. a test reset) does NOT update readest's `viewSettings.zoomLevel`. Pinch commit = `round(zoomLevel * lastPinchRatio)`, so a desynced store makes commit diverge from the live transform preview. Real pinches keep them in sync; only direct `setAttribute` breaks it. Cost me a long false-positive "shift" chase — reset zoom via a synthetic pinch, never `setAttribute`.
**Selection re-impl (NOT taken):** host-level selection via `caretRangeFromPoint` + dispatch `selectionchange` on the iframe doc IS viable (readest `handleSelectionchange` -> `makeSelection` -> popup; `getPosition` returns valid coords for scroll-page selections), but loses native OS selection handles/magnifier, and the popup is deferred until a real `touchend` sets `androidTouchEndRef` (Annotator.tsx). Abandoned in favour of native selection.
CDP-verified on Xiaomi (tap toggle, same-page pinch in/out no-shift, vertical scroll, horizontal pan, iframes `pointer-events:auto` when idle). Native selection itself needs a real finger (CDP synthetic touches don't engage the WebView long-press selection gesture).
@@ -0,0 +1,40 @@
---
name: toc-table-heading-clip-4439
description: "#4400 scroll-wrapper overflow:auto clips negative-margin bleed of decorative layout tables; hoist negative margins onto wrapper"
metadata:
node_type: memory
type: project
originSessionId: 6d1d7362-d152-4248-93c0-76f6aef92329
---
#4439: on a decorative TOC page (nested layout tables), the **top half of the
`CONTENTS` heading is clipped** in paginated mode (0.11.4 regression; 0.11.2 fine).
Reporter blamed [[table-dark-mode-tint-4419]] but that's dark-mode only — this is
light mode. Real cause is **#4400** (`scrollable.ts` + `getPageLayoutStyles`):
- v0.11.2 sized wide tables with `transform: scale()`**never clipped**.
- #4391 then #4400 replaced that with wrapping every `<table>` (and display
`<math>`) in `.scroll-wrapper { overflow: auto }` + `table { max-height: var(--available-height) }`.
- These EPUBs lay the contents out as a nested `<table class="bc" style="margin: -1em 0 0 1em">`
with `<p class="lh em16 ...">CONTENTS</p>` (`line-height:1em`, inside `div.em06`=0.6em).
The **negative top margin** pulls the table (and the heading's first line) above
the wrapper's `overflow:auto` content box, which clips it. Measured: heading top
~12.8px above the clip box = exactly `-1em` in the 0.6em context (~58% of the line).
- The `-fit` escape (`SCROLL_WRAPPER_FIT_CLASS``overflow:visible`) only checks
**horizontal** fit (`scrollWidth-clientWidth`). The table's positive `margin-left:1em`
inflates scrollWidth so it never gets `-fit`, stays `overflow:auto`, and clips.
**Fix** (PR for #4439): `hoistNegativeMargins(el, wrapper, win)` in `applyScrollableStyle`'s
`wrap()` — move any NEGATIVE computed margins from the wrapped element onto the wrapper
and zero them on the element. Keeps the box in place, lets the element sit flush so the
overflow box can't clip it; also de-inflates scrollWidth so a genuinely-fitting table
gets `-fit`. Positive/auto margins are left alone (over-wide tables still scroll; centered
tables stay centered). CSS can't do per-axis `overflow-x:auto; overflow-y:visible`
(spec coerces `visible``auto`), so margin-hoisting is the route, not per-axis overflow.
Repro is metric-sensitive (whether the inner table is `-fit`). Tests: browser test
`src/__tests__/document/paginator-table-toc-clip.browser.test.ts` + fixture
`repro-4439.epub` (real foliate paginator, asserts heading top not above its clip box);
unit cases in `scrollable.test.ts`. Verified against the literal book (`321123.epub`,
content-7.xhtml spine idx 12): clipped without fix, clean with it. Related:
[[paginated-texture-occlusion-4399]], [[inline-block-column-overflow]].
@@ -0,0 +1,20 @@
---
name: tts-highlight-granularity-setting
description: TTS highlight granularity (word/sentence) user setting and its two-point gating in TTSController
metadata:
node_type: memory
type: project
originSessionId: 33ddd196-7404-4af2-99ac-0d3b19b39b4e
---
Settings → TTS → "TTS Highlighting" boxed list has a **Granularity** select (first row, before Style): `Word` (default) / `Sentence`. Field `ttsHighlightGranularity: TTSHighlightGranularity` on `TTSConfig` (`src/services/tts/types.ts`, `src/types/book.ts`), default `'word'` in `DEFAULT_TTS_CONFIG`. UI in `TTSHighlightStyleEditor.tsx` (props `granularity`/`onGranularityChange`), persisted from `TTSPanel.tsx` via `saveViewSettings(..., false, false)` + a value-watching `useEffect` (mirrors `ttsMediaMetadata`).
Word-by-word highlighting only ever happens on **Edge TTS** (`supportsWordBoundaries() === true`); Web/Native always highlight per sentence. We assume every engine supports sentence highlighting, so picking `word` on a non-word-boundary engine naturally falls back to sentence.
**Gating lives at two points in `TTSController` (NOT one helper):**
1. `dispatchSpeakMark` suppression: `#suppressMarkHighlight = ttsClient.supportsWordBoundaries() && #highlightGranularity === 'word'`. With `sentence`, don't suppress → the sentence highlight is drawn at mark dispatch.
2. `prepareSpeakWords` early-return: `if (#highlightGranularity === 'sentence') return;`**gated on granularity only, NOT on `supportsWordBoundaries()`**. Reason: `prepareSpeakWords` is only called by EdgeTTSClient in prod (boundaries present), but `tts-controller.test.ts` calls it directly with the *web* client active (supportsWordBoundaries=false) and expects word highlighting. Adding a `supportsWordBoundaries()` check there would break those existing tests.
Controller learns the value via `setHighlightGranularity()` (called at creation in `useTTSControl.ts` next to `updateHighlightOptions`, and from a `useEffect` on `viewSettings.ttsHighlightGranularity`). Mock `TTSController` in `useTTSControl.test.tsx` must include `setHighlightGranularity: vi.fn()` or the speak path throws and emits no position/state.
Related: [[edge-tts-word-highlighting-4017]], [[tts-word-highlight-singletextnode-drift]], [[tts-sync-paragraph-rsvp-3235]].
@@ -0,0 +1,44 @@
---
name: webdav-credential-sync-4810
description: "WebDAV credentials weren't synced cross-device; add to settings whitelist + encrypted fields + mergeSettings deep-merge"
metadata:
node_type: memory
type: project
originSessionId: 1a4937d3-05a4-4e1e-b917-d56304b66f2b
---
Issue #4810: WebDAV credentials never synced across devices despite the
"Credentials" sync toggle being ON. WebDAV (`webdav.serverUrl/username/password/rootPath`)
was simply absent from the settings replica whitelist — only kosync/readwise/hardcover
were there.
Fix (`src/services/sync/adapters/settings.ts`):
- Added `webdav.serverUrl`, `webdav.username`, `webdav.password`, `webdav.rootPath`
to `SETTINGS_WHITELIST`.
- Added `webdav.username`, `webdav.password` to `SETTINGS_ENCRYPTED_FIELDS`.
- Deliberately EXCLUDED per-device bookkeeping: `enabled`, `deviceId`,
`lastSyncedAt`, sync sub-toggles. Mirrors KOSync, which syncs credentials
but NOT its `enabled` flag — a fresh device pre-fills the connect form and
the user clicks Connect (avoids auto-arming sync / rotating deviceId).
**Gotcha (the real trap):** `mergeSettings` in `replicaSettingsSync.ts` does a
top-level shallow merge (`{...current, ...patch}`) plus single-level deep
merges ONLY for explicitly-listed nested groups (globalViewSettings,
globalReadSettings, kosync, readwise, hardcover, dictionarySettings). Adding a
new nested whitelist group WITHOUT a matching deep-merge case there means the
shallow merge REPLACES the whole sub-object with the partial patch, wiping
sibling per-device fields (here: `webdav.enabled`/`deviceId`/sub-toggles would
be clobbered to undefined on every pull). Always add the `if (patch.X)` deep
merge when adding `X.*` paths to `SETTINGS_WHITELIST`.
Encrypted-path plumbing is generic: `ENCRYPTED_PATHS` in replicaSettingsSync.ts
derives from `SETTINGS_ENCRYPTED_FIELDS`, and replicaPublish uses
`adapter.encryptedFields` — so listing the two webdav fields there is enough
to gate them behind the credentials toggle + crypto session.
Also updated the credentials-category description (SyncCategoriesSection.tsx)
to list WebDAV; migrated the i18n key in all 33 locale JSONs manually (NOT via
`pnpm i18n:extract`, which prunes valid keys — see [[i18n-extract-prunes-keys]]).
Related: [[multiwindow-settings-clobber-4580]], [[webdav-metadata-sync-4756]],
[[webdav-connect-nullified-4780]].
+1
View File
@@ -76,6 +76,7 @@ docs/superpowers
.context/
.claude/settings.local.json
.claude/skills
.claude/plans
# Playwright web e2e
/test-results/
@@ -137,6 +137,19 @@
</intent-filter>
<intent-filter >
<action android:name="android.intent.action.VIEW" />
<!-- ChromeOS ARC++ uses a different action for deep links -->
<action android:name="org.chromium.arc.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="com.googleusercontent.apps.209390247301-ctpmep68ppfa56r1b8tr35e4qi4p60kq" />
</intent-filter>
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
<!-- tauri-file-associations. AUTO-GENERATED. DO NOT REMOVE. -->