From 787641b5b1f8f6d4b40416978ad478fca27a4ef5 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 23 Jun 2026 00:52:39 +0800 Subject: [PATCH] chore(agent): update agent memories (#4737) --- apps/readest-app/.claude/memory/MEMORY.md | 9 ++++ .../memory/android-themed-icon-4733.md | 52 +++++++++++++++++++ ...tor-onload-listener-leak-paragraph-mode.md | 25 +++++++++ .../memory/dict-popup-font-size-4443.md | 45 ++++++++++++++++ .../kosync-connect-false-positive-4692.md | 20 +++++++ .../native-tts-offline-autoadvance-4613.md | 18 +++++++ .../paragraph-mode-toggle-resume-4717.md | 20 +++++++ .../pdf-scroll-mode-wheel-double-4727.md | 26 ++++++++++ .../memory/pdf-spread-canvas-seam-4587.md | 52 +++++++++++++++++++ .../memory/sync-synced-at-cursor-4678.md | 26 ++++++++++ 10 files changed, 293 insertions(+) create mode 100644 apps/readest-app/.claude/memory/android-themed-icon-4733.md create mode 100644 apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md create mode 100644 apps/readest-app/.claude/memory/dict-popup-font-size-4443.md create mode 100644 apps/readest-app/.claude/memory/kosync-connect-false-positive-4692.md create mode 100644 apps/readest-app/.claude/memory/native-tts-offline-autoadvance-4613.md create mode 100644 apps/readest-app/.claude/memory/paragraph-mode-toggle-resume-4717.md create mode 100644 apps/readest-app/.claude/memory/pdf-scroll-mode-wheel-double-4727.md create mode 100644 apps/readest-app/.claude/memory/pdf-spread-canvas-seam-4587.md create mode 100644 apps/readest-app/.claude/memory/sync-synced-at-cursor-4678.md diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 0c66ec26..f0a00bd5 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -29,6 +29,8 @@ - [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits - [Inline-block column overflow](inline-block-column-overflow.md) — chapter skips to "Reference materials", clipping a large middle; EPUB wraps body in `display:inline-block` div → atomic-inline box can't fragment across columns → vertical overflow clipped (scrollHeight≫clientHeight). Fix = paginator `#demoteUnfragmentableBoxes` in `columnize` (col-mode, over-tall atomic-inline→fragmentable block). Renders via goTo/next but pages unreachable; scrolled mode unaffected - [Fixed-layout fit-width page-turn scroll reset (#4683)](fixed-layout-paginated-scroll-reset-4683.md) — tall fit-width PDF/FXL page opens scrolled-to-END on next-page (WebKit ONLY; Blink resets on content-swap). `#render` recentred scrollLeft but never reset scrollTop; fix = `computePaginatedScroll` helper + `pageTurn` flag set true only in `#showSpread`/`#goLeft`/`#goRight`. Proved on real Safari 605.1.15 (==reporter), NOT Android-reproducible (CDP showed Blink already 0) +- [PDF spread 1px white seam (#4587)](pdf-spread-canvas-seam-4587.md) — spine seam at fractional dpr (Win 150%); `canvas.width=viewport.width` truncates fractional bitmap → canvas renders ~1 device-px narrow under the `1/dpr` documentElement scale → left page stops short of spine. Fix = pin `canvas.style.width/height = viewport.{width,height}` so bitmap fills the box. dpr=2 can't repro; CDP `--force-device-scale-factor=1.5` repro recipe inside +- [PDF scrolled-mode wheel double-scroll (#4727)](pdf-scroll-mode-wheel-double-4727.md) — fixed-layout/PDF scrolled mode scrolls 2× (instant lurch) over the page vs smooth over the margin; `#loadScrollPage` iframe wheel listener did a manual `scrollBy('instant')` ON TOP OF native scroll-chaining from the `scrolling="no"` iframe. Fix = drop the manual scrollBy, keep `#setScrollIframeInteraction(false)`. Browser-lane test mounts real `` + synthetic wheel; needs real layout (jsdom can't) ## Critical Files (Most Bug-Prone) - `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes) @@ -45,6 +47,8 @@ - [koplugin note deletion sync](koplugin-note-deletion-sync.md) — koplugin push only walked LIVE annotations so deletions never reached the server; fix = `recordDeletion` persists a `deletedAt` tombstone to `doc_settings.readest_sync.deleted_notes`, `push` folds+clears them; deletion signal in `onAnnotationsModified` is `items.index_modified < 0` - [koplugin stats sync (#4666)](koplugin-stats-sync.md) — reading-stats sync (pull on open / push on close, whole statistics.sqlite3 delta, cursor-based); 3-bug chain: plain-table-not-LuaSettings `settings:readSetting` crash; missing required books/notes/configs; statBooks/statPages need `optional_params` (Spore expected=required∪optional, `payload`≠accepted); large-backlog UI-stall + silent-retry risk unfixed - [Statusless books re-pinned to top (#4677)](sync-statusless-book-rebump-4677.md) — never-statused (locally-imported, never-pulled) books send `reading_status:undefined` vs server `null`; server POST `statusChanged` (`undefined!==null`) rewrites `updated_at=now()` every push → batch-identical ts pins them top of date-sort; 1-day re-sync window amplifies; fix = `(a??null)!==(b??null)`. On-device CDP PUSH_SENT-vs-RETURNED proof recipe +- [Decouple pull cursor via server synced_at (#4678)](sync-synced-at-cursor-4678.md) — books-only `synced_at` timestamptz + `BEFORE INS/UPD` trigger = server-stamped pull cursor; GET filters `synced_at>since` (drop deleted_at), POST status-merge drops `updated_at=now()` → propagates w/o reordering date-read. Backfill BEFORE trigger; `synced_at≥updated_at` ⇒ superset ⇒ old web+koplugin compatible (koplugin untouched, shared pull/push cursor) +- [KOSync connect() false-positive (#4692)](kosync-connect-false-positive-4692.md) — connect() accepted any 2xx (HTML web-UI page) as login → misconfigured Server URL silently "connects", pulls 0%/pushes fail no-error; server tell = PUT hits Spring `ResourceHttpRequestHandler` (SPA fallback); fix = validate koreader JSON resp; per-device settings = #1 suspect when one platform syncs ## Testing - [format:check is a separate gate](verify-format-check-gate.md) — `pnpm lint` (tsgo + biome lint) does NOT run the Biome formatter; `pnpm format:check` is its own CI (build_web_app) + pre-push gate. Run it before pushing. `--no-verify` when pre-push trips on unrelated working-tree WIP; biome stdin-format recipe; zsh no-word-split gotcha @@ -72,8 +76,12 @@ - [Dict lookup → OEM browser hijack (#4559)](dict-lookup-browser-hijack-4559.md) — VIVO system-dict lookup opened the browser not Eudic. PRIMARY: no `` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `` to plugin manifest). SECONDARY: browser registers PROCESS_TEXT + is default → filter browsers in pure `decideLookupDispatch` (explicit/chooser/unavailable). Remember-the-pick via `IntentSender`+`EXTRA_CHOSEN_COMPONENT`→`LookupChoiceReceiver`→SharedPreferences (`ACTION_CHOOSER` has no native Always); reset row in `CustomDictionaries.tsx` - [Android sideload same versionCode](android-sideload-same-versioncode.md) — sideloaded APK reinstall allows EQUAL versionCode (only strictly-lower blocked); Play Store's increment rule does NOT apply to sideload. Nightly APKs share base versionCode and still install. Corrects a plausible-but-wrong review claim - [Large-PDF OOM range flood (#3470)](pdf-oom-range-flood-3470.md) — 50MB+ PDF import/open crash = foliate makePDF firing ALL pdf.js range reads un-awaited (753 concurrent fetch→shouldInterceptRequest→Java byte[] → 512MB heap OOM), NOT whole-file load; official viewer survives via browser ~6-conn/host cap; fix = MAX_CONCURRENT_RANGES=6 queue in makePDF; on-device CDP recipe; Xiaomi13/WV147 won't OOM but flood 753→6 verified +- [Android themed (monochrome) icon (#4733)](android-themed-icon-4733.md) — Material-You themed icon; #2353 dropped the `` layer when it added 22% inset. `gen/android` gitignored but customized res files force-added; CI `tauri android init`+`tauri icon`+`git checkout .` → committed gen = build truth; `tauri icon` emits NO monochrome so PNGs/vector MUST be force-committed. Tint = SRC_IN (alpha-only) → opaque art blobs; add character via negative space (narrow center-spine gap, ImageMagick alpha-mask). Emulator verify: Wallpaper&style→Home screen→Themed icons; home/dock themed, app drawer stays color; gradle-standalone panics tauri-cli, use `tauri android build --target aarch64` ## Feature Notes +- [Dict popup font size (#4443)](dict-popup-font-size-4443.md) — adjustable dictionary popup text size; `DictionarySettings.fontScale` (synced via whitelist) → `--dict-font-scale` on `[data-dict-content]`. MDict is shadow-DOM so `::part(dict-content)` (host class `dict-shadow-host`) is the only cross-boundary hook; light-DOM Tailwind `text-*` re-based to `em` within scope. CSS contract needs the browser lane +- [Paragraph mode toggle/resume (#4717, PR #4725)](paragraph-mode-toggle-resume-4717.md) — Shift+P double-toggle = `eventDispatcher.dispatch` iterates LIVE Set while awaiting → re-subscribing effect double-fires (snapshot `[...listeners]`); overlay keys = dialog pattern (focus + own onKeyDown, NOT global onEscape); resume rewind = don't scroll view on enter/exit + resume from fresh `view.lastLocation.cfi` (rAF-debounced store + malformed stored paragraph CFI sent it to chapter start). claude-in-chrome keystrokes buffer/drop on the reader; menu clicks reliable +- [Annotator onLoad listener leak → paragraph mode degrades (#4735)](annotator-onload-listener-leak-paragraph-mode.md) — `onLoad` (per foliate `load`, incl. preloaded sections) attached renderer-`scroll` + Android `native-touch` listeners to long-lived `view.renderer`/global `eventDispatcher` w/o cleanup → unbounded; paragraph `goTo` scrolls every step → runs all; Android-gated cost; restart-fixed. Fix = `useRendererInputListeners` once-per-view + resolve primary doc/index at fire time. Pattern: listeners on session-lived targets inside per-section handlers leak - [Stripe highest-active plan (#4694)](stripe-plan-highest-active-4694.md) — `plans.plan` must be MAX over active subs not last webhook (Plus→Pro overlap race); `getHighestActivePlan` in create+cancel paths; IAP unaffected (subscription groups); expand depth=4 cap; opt-in live test = `it.skipIf`+dynamic-import (supabase atob crash), `npx dotenv -e .env -- vitest run `, pre-push tsgo whole-tree → `--no-verify` - [Save image to gallery (Android, #4680)](save-image-to-gallery-android.md) — image-viewer Save button → MediaStore on Android (share sheet can't save-to-file: ACTION_SEND has no file-manager target); sharekit 0-byte self-copy bug (Temp==cacheDir); tsgo misses abstract-class conformance (real tsc catches); on-device CDP verify recipe - [Webtoon Mode (#3647)](webtoon-mode-3647.md) — seamless no-gap scrolled reading for image books (PRs #4662 + foliate-js#30); fixed-layout scroll mode is fit-width by construction (ignores `zoom`, only `scale-factor`); `scroll-gap` attr→`--scroll-page-gap` var; clear-on-leave in BOTH ViewMenu effect AND Shift+J; worktree submodule has local-path origin (push SHA direct to fork) @@ -84,6 +92,7 @@ - [Word Lens inline gloss (feat/word-wise)](wordlens-feature.md) — Kindle-style native-language hint above hard words; CFI-safe via `` (epubcfi hoist+merge, NOT just tree-walk); TTS/search isolation (tags:['rt'] + rangeTextExcludingInert + search attributes:['cfi-inert']); gloss data = curated starters, full asset built by `build-wordlens-data.mjs` (ECDICT/CC-CEDICT+HSK) - [iOS instant-dict double popup](ios-instant-dict-double-popup.md) — iOS emits multiple `selectionchange`/long-press → instant sys-dict fired 2-3×; deferredAction `fired` once-per-gesture latch + `beginGesture`; tap-to-deselect re-fire fixed by `isLongPressHold` 300ms gate (!isAndroid); Word Lens `wantWordLensDict` now routes via `handleDictionary` to honor system dict - [Native local iOS TTS (#4676)](native-ios-tts-4676.md) — AVSpeechSynthesizer Swift plugin mirroring Android `TextToSpeech`; shared TS `NativeTTSClient`+Rust layer already platform-agnostic, only Swift stub + 2 gates missing. `init`=reserved→`@objc(init:)`; pause==stop + NEVER emit `end` on `didCancel`; AVAudioSession owned by native-bridge `use_background_audio`; `getMediaSession()` reorder (native before `navigator.mediaSession`); MPRemoteCommandCenter shared w/ native-bridge media-keys (token removal); rate `pow^(1/2.5)` invert→AV 0..1 +- [Native TTS offline auto-advance halt (#4613, #4408, PR #4716)](native-tts-offline-autoadvance-4613.md) — System TTS offline stops at chapter end/random; `#speak` only advances on `'end'`, native terminal `'error'` (unsynthesizable char) dead-ends + wedges `'playing'`; fix = native-gated SKIP-on-error (`forward()`, not retry) + consecutive-error cap→stop; Google local engine can't repro; `__TAURI_INTERNALS__.invoke` reverts on Next.js nav - [Edge TTS word highlighting (#4017, PR #4566)](edge-tts-word-highlighting-4017.md) — keep sentence marks, add word highlight via `audio.metadata` WordBoundary (verbatim input span, 100-ns ticks) synced to `audio.currentTime` by rAF; readaloud endpoint gates on UA (Edg, non-headless) NOT Origin; fixed browser `new WebSocket(url,{headers})` SyntaxError (wss never worked on web); overlay = `` in FOLIATE-PAGINATOR shadow root; dev-web verify recipe (browse --proxy + UA spoof, never Origin header) - [Reference Pages (#672+#4542, PR #4549)](reference-pages-672-4542.md) — 'reference' progressStyle from foliate `pageItem`/`book.pageList` (numeric-max total rule, roman-tail safe); per-book `referencePageCount` via skipGlobal save; verification EPUBs + dev-web synthetic drag-drop import trick; locale-tail rebase-conflict recipe (checkout --ours → re-extract → re-translate) - [OPDS Firefox strict-XML parse (#4479)](opds-firefox-strict-xml-4479.md) — MEK feed has junk after ``; Firefox DOMParser → `` (silent back-nav), Chrome lenient; `parseOPDSXML` slices root start→last close tag; jsdom mirrors Firefox; wired into page.tsx + validateOPDSURL + feedChecker (latter also #4181 `looksLikeXMLContent` swap) diff --git a/apps/readest-app/.claude/memory/android-themed-icon-4733.md b/apps/readest-app/.claude/memory/android-themed-icon-4733.md new file mode 100644 index 00000000..7f8fc79a --- /dev/null +++ b/apps/readest-app/.claude/memory/android-themed-icon-4733.md @@ -0,0 +1,52 @@ +--- +name: android-themed-icon-4733 +description: "Android Material-You themed (monochrome) launcher icon — restoring it (#4733), the gen/android force-commit pipeline, and emulator verification" +metadata: + node_type: memory + type: project + originSessionId: 6bc82dac-a705-4ef2-ab28-c13b43f48a46 +--- + +Issue #4733 = add Android themed (Material You / monochrome) launcher icon. It had +existed (#2122/#2153 added `ic_launcher_monochrome.png`) but PR #2353 ("fixed +launcher icon size") rewrote the committed adaptive icon to inset the foreground +22% and **silently dropped the `` layer**, so themed icons stopped +working. Fix = re-add `` +to `ic_launcher.xml` + ship the monochrome mipmaps. + +**Android icon pipeline (non-obvious).** `src-tauri/gen` is gitignored, BUT specific +customized res files are **force-added** (tracked): `mipmap-anydpi-v26/ic_launcher.xml`, +`drawable/ic_launcher_background.xml`, `values/themes.xml`, `splash_icon.png`. CI +(release/nightly/android-e2e) does `rm -rf gen/android` → `tauri android init` → +`tauri icon ../../data/icons/readest-book.png` → **`git checkout .`** (restores the +tracked customizations) → build. So **the committed gen files are the build's source +of truth.** `tauri icon` (CLI 2.10.1) writes gen mipmaps + a DEFAULT `ic_launcher.xml` +(foreground+background only) and does NOT emit a monochrome layer — so the monochrome +PNGs (or a vector drawable) MUST be force-committed into `gen/.../res/` to survive +`git checkout .`. `git add -f apps/.../gen/.../mipmap-*/ic_launcher_monochrome.png`. +`src-tauri/icons/android/*` is the historical master but is NOT what the build reads. + +**Themed tint = SRC_IN (alpha only).** The launcher replaces the monochrome layer's +RGB with the wallpaper tint, keeping only alpha → any fully-opaque artwork flattens +to a solid blob (the original desaturated-logo monochrome lost all detail). Convey +character via negative space. For Readest we kept the existing artwork and carved a +**narrow vertical center-gap (spine)** via an alpha-multiply mask (ImageMagick: +`magick src -alpha extract a.png; magick -size WxH xc:white -fill black -draw "roundrectangle ..." g.png; magick a.png g.png -compose multiply -composite na.png; magick src na.png -alpha off -compose CopyOpacity -composite out.png`), +gap ≈ centered, width ~4% of content, from ~3%→84% of content height (pages stay +joined at the binding). Preview-as-themed = tint `-colorize`, inset to central 56% +(=22% inset), composite over dark bg, circular mask. + +**Emulator verify (Pixel_9_Pro AVD, Google Play image, NexusLauncher).** Themed icons +toggle: Wallpaper & style (`am start -n com.google.android.apps.wallpaper/com.android.customization.picker.CustomizationPickerActivity`) +→ "Home screen" tab → "Themed icons" switch. Only the **home screen/dock** is themed; +the **app drawer keeps full color** (expected, not a bug). `uiautomator dump` returns +"null root node" on the wallpaper picker (SurfaceView) → navigate by screenshot +coords. Build for emulator = `pnpm tauri android build --debug --target aarch64 --apk` +(NDK_HOME must be set). Gradle-standalone (`./gradlew :app:assembleUniversalDebug`) +fails: the `rustBuild*` task shells `pnpm tauri ...` which panics at +`tauri-cli/src/mobile/mod.rs:403` unless driven by `tauri android build`. Confirm the +APK packaged it: `aapt2 dump xmltree --file res/mipmap-anydpi-v26/ic_launcher.xml app.apk` +should show an `E: monochrome` node. Regression guard: +`src/__tests__/android/themed-icon.test.ts` (asserts `` in the XML + a +tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]] +(Android resource/manifest gotchas), [[android-cdp-e2e-lane]]. diff --git a/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md b/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md new file mode 100644 index 00000000..d86e7550 --- /dev/null +++ b/apps/readest-app/.claude/memory/annotator-onload-listener-leak-paragraph-mode.md @@ -0,0 +1,25 @@ +--- +name: annotator-onload-listener-leak-paragraph-mode +description: "Paragraph mode degrades over chapters on Android (#4735) — Annotator onLoad leaked renderer-scroll + native-touch listeners on long-lived objects; per-view fix + the reusable per-section-leak pattern" +metadata: + node_type: memory + type: project + originSessionId: 980f8d79-9360-4402-bd49-8dd389200c1e +--- + +PR #4735 (`fix/annotator-input-listener-leak`). Reported: reading a 3000-chapter web novel in **paragraph reading mode** on Android (Z Fold 7), paragraph transitions get sluggish after a few chapters and keep degrading until app restart. Classic per-section-transition resource leak. + +**Root cause — Annotator `onLoad` attaches listeners to objects that OUTLIVE the section.** `Annotator.tsx`'s `onLoad` (wired via `useFoliateEvents(view, { onLoad })` to the foliate `load` event, which fires once per section document load) did: +- `view.renderer.addEventListener('scroll', handleScroll)` — never removed +- `view.renderer.addEventListener('scroll', () => repositionPopups())` — anonymous, unremovable +- Android: `eventDispatcher.on('native-touch', handleNativeTouch)` — never `off`'d + +`view.renderer` is created ONCE per book (`createElement('foliate-view')` + `view.open()` in `FoliateViewer.tsx`), lives the whole session; the global `eventDispatcher` too. So every `load` permanently adds listeners. **foliate fires `load` for PRELOADED neighbour sections too** (`paginator.js#loadAdjacentSection` → `dispatchEvent(new CustomEvent('load',…))`, `#preloadNext` loads up to 8), so the accrual is several-per-chapter, not one. The doc-scoped `detail.doc.addEventListener(...)` listeners do NOT leak (the section iframe is destroyed by foliate's `#destroyView`, taking them with it). Only the renderer-/dispatcher-scoped ones leak. + +**Why paragraph mode + Android specifically.** Every paragraph advance calls `renderer.goTo({index, anchor})` (`focusCurrentParagraph`), which scrolls the renderer container → `paginator.js:~1161` `this.#container.addEventListener('scroll', () => { if(!#isAnimating) dispatchEvent(new Event('scroll')) })` → runs ALL accumulated scroll listeners. Normal paginated reading scrolls only on occasional page turns, so the same leak is far less felt. Cost is REAL on Android: `handleScroll` (useTextSelector.ts, the `#873` selection-pin workaround) early-returns unless `osPlatform==='android'`, then calls `getViewSettings`; `native-touch` is Android-only. Restart recreates the view/renderer → cleared (the reporter's workaround). + +**Fix = `useRendererInputListeners(view, {...})` hook** (`src/app/reader/hooks/`): registers the renderer `scroll` + (Android) `native-touch` listeners ONCE per view in an effect keyed `[view, enableNativeTouch]`, with cleanup; handlers routed through refs so re-renders never re-subscribe. The native-touch handler now resolves the CURRENT primary section's doc/index at fire time (`view.renderer.getContents().find(c=>c.index===primaryIndex)`) instead of capturing a load's doc/index (a load may be an off-screen preload — and the old code fan-fired EVERY loaded section's handler per touch, calling handleTouchEnd/handlePointerUp N times; new code fires once = strictly more correct). Dropped the redundant `scroll→repositionPopups` (a dedicated effect already repositions popups on scroll). `listenToNativeTouchEvents()` just sets one global `window.onNativeTouch` that re-dispatches `native-touch` via eventDispatcher — idempotent, fine to call once/view. + +**Reusable pattern (the lesson).** Listeners attached inside a per-section / per-event handler (`onLoad`, `load`, relocate, create-overlay) to an object that outlives that event (`view.renderer`, global `eventDispatcher`, `window`, `document`) LEAK one set per event. Audit `addEventListener`/`eventDispatcher.on` inside `onLoad`-style handlers: if the target isn't the per-section `detail.doc` (which dies with the iframe), it must move to a per-view `useEffect` with cleanup. `eventDispatcher` (`utils/event.ts`) stores async listeners in a per-event `Set` keyed by callback reference; fresh closures each call never dedupe → unbounded. + +**Verify gotchas.** Hook unit-tested with `renderHook` + a `MockRenderer extends EventTarget` tracking scroll listeners + a mocked `eventDispatcher` Set; assert size stays 1 across 20 re-renders, latest-handler routing, unmount→0, Android gate. NOTE: creating the mock `view` INSIDE the renderHook callback churns view identity → the `[view]`-keyed effect re-runs each render (still no leak — cleanup keeps Set at 1 — but `listenToNativeTouchEvents` call-count grows); hoist `view` outside to mirror the real stable `getView(bookKey)`. Needs on-device Android verification (Android-gated paths). Related: [[paragraph-mode-toggle-resume-4717]], [[tts-sync-paragraph-rsvp-3235]], [[android-nativefile-remotefile-io]]. diff --git a/apps/readest-app/.claude/memory/dict-popup-font-size-4443.md b/apps/readest-app/.claude/memory/dict-popup-font-size-4443.md new file mode 100644 index 00000000..59704da7 --- /dev/null +++ b/apps/readest-app/.claude/memory/dict-popup-font-size-4443.md @@ -0,0 +1,45 @@ +--- +name: dict-popup-font-size-4443 +description: Adjustable dictionary popup font size via ::part() + em-rebasing; the only cross-shadow font hook for MDict +metadata: + node_type: memory + type: project + originSessionId: b105ba93-61b7-4d28-a269-1201a7be89bd +--- + +#4443 — adjustable dictionary popup font size (independent of the reading view). +SHIPPED: merged to main via PR #4734. + +**The lever** = `DictionarySettings.fontScale` (number, default 1), set in +Settings → Language → Dictionaries (`SettingsSelect`, 85–175%). Stored in the +dictionary settings; SYNCED by adding `dictionarySettings.fontScale` to +`SETTINGS_WHITELIST` (whole-field LWW, like providerOrder). `setFontScale` in +`customDictionaryStore` + default-merge in `loadCustomDictionaries` +(`?? DEFAULT_DICTIONARY_SETTINGS.fontScale`). + +**Plumbing**: `useDictionaryResults` returns `fontScale`; `DictionaryResultsBody` +puts `data-dict-content` + inline `--dict-font-scale` on each per-tab container +(the `setContainerRef` div). All CSS lives in `globals.css`. + +**Two non-obvious CSS facts that drove the design:** +1. **MDict renders into a Shadow DOM** (`shadowHost.attachShadow`, the only + provider that does) → its body is unreachable by ordinary popup CSS. + `::part(dict-content)` is the ONLY hook. So `mdictProvider` sets + `body.setAttribute('part','dict-content')` AND adds a stable host class + `dict-shadow-host` (the `::part()` rule needs a host selector subject). + `--dict-font-scale` inherits across the shadow boundary, so the outer rule + `…::part(dict-content){font-size: calc(var(--dict-font-scale,1) * 0.875rem)}` + resolves it. The dict's own shadow CSS never targets our wrapper `
`, so + no cascade fight — em-based dict content scales from it, px-based stays fixed + (expected for a font-size lever). +2. **Light-DOM providers size text with Tailwind `text-*` = root-relative `rem`**, + which a container `font-size` can't move. Fix = re-base the utilities to `em` + WITHIN `[data-dict-content]` only: `[data-dict-content] .text-sm{font-size:.875em}` + etc. Higher specificity than the bare utility + declared after `@tailwind + utilities` → wins, no `!important`. Container itself = `calc(scale * 1em)`. + +**Verify**: the CSS contract (em-rebasing + `::part` + var inheritance) needs a +real browser — jsdom has no layout. Covered by +`dict-popup-font-size.browser.test.ts` (scale 1 → 18/14/14px, scale 1.5 → +27/21/21px, incl. the shadow body). Provider/store/whitelist sides have jsdom +unit tests. See [[css-style-fixes]]. diff --git a/apps/readest-app/.claude/memory/kosync-connect-false-positive-4692.md b/apps/readest-app/.claude/memory/kosync-connect-false-positive-4692.md new file mode 100644 index 00000000..370b3d8f --- /dev/null +++ b/apps/readest-app/.claude/memory/kosync-connect-false-positive-4692.md @@ -0,0 +1,20 @@ +--- +name: kosync-connect-false-positive-4692 +description: "KOSync connect() accepted any 2xx (even an HTML web-UI page) as login → misconfigured Server URL silently \"connects\" but never syncs" +metadata: + node_type: memory + type: reference + originSessionId: 43e853c2-58ea-42f0-97ed-66aa3f65e4d1 +--- + +#4692 (PR #4711): KOReader Sync to a self-hosted Grimmory/Booklore server failed on Android (worked on iOS). Root cause was a **misconfigured Server URL** that resolved to the host's static web UI instead of the sync endpoint, made undebuggable by a Readest gap. + +**Server-side tell (the smoking gun):** Android `PUT /syncs/progress` was handled by Spring's `ResourceHttpRequestHandler` → `HttpRequestMethodNotSupportedException: Request method 'PUT' is not supported`. That handler is Booklore's SPA/static fallback — so the request reached the server but **missed the koreader controller** and hit the catch-all static handler. GET requests (auth/pull) silently get the HTML index with 200; only PUT errors (static handler rejects non-GET/HEAD). + +**Readest gap:** `KOSyncClient.connect()` treated any 2xx from `/users/auth` (or `/users/create`) as success. An HTML web-UI page returns 200 → false-positive "connected"; then pulls show 0% and pushes fail with no error surfaced. Matches the classic "no errors reported, still 0%" report. + +**Fix:** validate the auth/registration response is an actual koreader JSON object (real server → `{"authorized":"OK"}`; HTML fails `response.json()`), else return "Not a KOReader Sync server. Check the Server URL." (`isKoSyncJsonResponse` helper in `KOSyncClient.ts`). Catches misconfig at setup, when actionable. + +**Still silent (intentional follow-up, not done):** per-sync push/pull failures. `getProgress`/`updateProgress` collapse "request failed" and "no remote data" into the same `null`/`false`; naive toasting would fire on every transient auto-push (5s). Needs noise-aware design before surfacing. + +KOSync settings are **per-device** (no Readest account → not synced across devices), so iOS vs Android URLs are entered independently — the #1 suspect when one platform syncs and the other doesn't. Related: [[kosync-cfi-spine-resolution]], [[empty-start-cfi-sync]]. diff --git a/apps/readest-app/.claude/memory/native-tts-offline-autoadvance-4613.md b/apps/readest-app/.claude/memory/native-tts-offline-autoadvance-4613.md new file mode 100644 index 00000000..92a67351 --- /dev/null +++ b/apps/readest-app/.claude/memory/native-tts-offline-autoadvance-4613.md @@ -0,0 +1,18 @@ +--- +name: native-tts-offline-autoadvance-4613 +description: "Android/iOS System TTS stops at chapter end (or random intervals) offline — controller only auto-advances on 'end', native terminal 'error' dead-ends + wedges state" +metadata: + node_type: memory + type: project + originSessionId: 5ae3d6fc-9082-4ba2-b7d4-e02dd277ee8f +--- + +# Native System TTS offline auto-advance halt (#4613, #4408) + +**Symptom:** With Android System TTS (or iOS) **offline**, read-aloud stops — #4613 "at the end of the chapter, won't go to next chapter" (Samsung S25, Chinese voices); #4408 "random intervals" (GrapheneOS, Supertonic engine). Then the play/headphone controls feel **wedged**; #4408 also flashes the "Please log in to use advanced TTS features" toast on manual restart (separate client-selection path — controller briefly tries Edge). + +**Root cause (`TTSController.#speak`):** auto-advance fires ONLY on `lastCode === 'end'`. The native client surfaces an offline engine failure as a terminal **`'error'`** code (Android `UtteranceProgressListener.onError`). Usually a **specific unsynthesizable utterance** (an unsupported CHARACTER — chrox's insight, fits online/offline asymmetry: engines network-fall-back for hard chars when online), hit on the new chapter's first utterance. On `'error'`: no `forward()` → playback dead-ends; `this.state` stays `'playing'` → controls wedge (restart re-errors on the same chunk). Edge/Web throw instead (caught by `error()` → state 'stopped'), so only **native** hits this. Engine-specific: Google local voices emit `onDone` fine, so it doesn't reproduce on every device. + +**Fix (PR #4716, `#speak` only):** gate `canSkipOnError = this.ttsClient === this.ttsNativeClient`. On terminal `'error'` (native, playing, !aborted, !oneTime): **SKIP the chunk and `forward()`** — same as `'end'` — because re-speaking deterministically-bad text just fails again (do NOT retry; first attempt was retry-the-same-chunk which is futile for an unspeakable char). Bound `#consecutiveSpeakErrors` (reset on `'end'`); when it exceeds `TTS_NATIVE_SPEAK_MAX_CONSECUTIVE_ERRORS=5` → `await this.stop()` (graceful: wholly-unusable engine stops instead of silently racing to book end; leaves 'playing' so controls recover). Edge/Web byte-for-byte unchanged. Tests (`tts-controller.test.ts` "native TTS offline error recovery (#4613, #4408)"): skip-advances-past-bad-chunk (forward spied) + cap-stops-gracefully (key off `state.attempts` NOT `state` — controller starts 'stopped' and `forward()` transiently re-enters 'stopped', so `waitFor(state==='stopped')` false-matches). + +**On-device verification reality (Xiaomi 13 fuxi, Android 16, WebView 147, Google TTS):** CANNOT reproduce the fault — offline auto-advance works, even offline+screen-off (foreground-audio service keeps the WebView UNthrottled; Google local engine emits onDone offline). Matches maintainer's non-repro. Needs the reporter's engine (Samsung/Supertonic/Chinese-network voice). Force the engine-error path on this device by setting a `*-network` voice offline. See [[cdp-android-webview-profiling]] for the CDP recipe; gotcha: `window.__TAURI_INTERNALS__.invoke`/`runCallback` get RE-INJECTED on Next.js client nav (wrappers revert) — `console.log` wrapping persists, so trace via the `[TTS] speak` / `[TTS] Initialized TTS for section N` logs instead. Related: [[tts-fixes]], [[tts-browser-e2e-harness]]. diff --git a/apps/readest-app/.claude/memory/paragraph-mode-toggle-resume-4717.md b/apps/readest-app/.claude/memory/paragraph-mode-toggle-resume-4717.md new file mode 100644 index 00000000..24b8f269 --- /dev/null +++ b/apps/readest-app/.claude/memory/paragraph-mode-toggle-resume-4717.md @@ -0,0 +1,20 @@ +--- +name: paragraph-mode-toggle-resume-4717 +description: "Paragraph mode (#4717) Shift+P double-toggle, dialog key handling, and chapter-start rewind — root causes + reusable gotchas (eventDispatcher re-entrancy, foliate lastLocation vs store progress)" +metadata: + node_type: memory + type: project + originSessionId: a19a021a-0e84-4bf6-bde4-02b115b6306f +--- + +PR #4725 fixed three Shift+P paragraph-mode bugs (#4717). Follow-on to #4723 (Alt+P proofread + initial overlay attempt). Three reusable, non-obvious findings: + +**1. `eventDispatcher.dispatch` live-Set re-entrancy (the widest-reach gotcha).** `dispatch()` iterated the live `asyncListeners` Set while `await`ing each listener. A listener that triggers a React state change which re-runs an effect that re-subscribes a handler for the SAME event gets that new handler invoked in the same dispatch loop → the event double-fires. Symptom here: one Shift+P toggled paragraph mode twice (exit→re-enter "flash"); `useParagraphMode`'s `toggle-paragraph-mode` subscription effect has `paragraphConfig.enabled` in deps, so the exit's awaited `dispatch('paragraph-mode-disabled')` re-subscribed mid-loop. Fix = snapshot before iterating: `for (const l of [...listeners])` in `dispatch` (`dispatchSync` already did). **Applies to ANY event whose handler re-subscribes.** + +**2. Overlay key handling = dialog/alert pattern, NOT a global window listener.** The maintainer (chrox) explicitly rejected wiring `onEscape` into useShortcuts. Correct pattern: the overlay's container is `role=dialog tabIndex=-1`, gets `containerRef.current.focus({preventScroll:true})` on open, and handles Escape / toggle shortcut / nav in its OWN `onKeyDown` (stopPropagation so the global handler can't double-fire). Add `outline-none` to the programmatically-focused non-tab-stop container or it draws a focus ring around the whole viewport. The old capture-phase `window.addEventListener('keydown',...,true)` + `stopImmediatePropagation()` swallowed global shortcuts and only fired when focus was on the parent doc (not the foliate iframe). + +**3. Paragraph-mode resume rewound to chapter start.** Two causes: (a) entering/exiting scrolled the underlying view to the focused paragraph's START via `renderer.goTo`/`scrollToAnchor`; when the page-top paragraph began on the previous page this rewinds a page, and repeated enter/exit accumulates. Fix = `focusCurrentParagraph(align=false)` on resume/first-mount, drop the exit `scrollToAnchor`; navigation keeps `align=true`. (b) resume preferred the rAF-debounced `readerStore` progress and a stored last-paragraph CFI (`view.getCFI(docIndex, paragraphBlockRange)`) that comes out MALFORMED (e.g. `epubcfi(/6/18!,/4/110,/4)`) and `resolveCFI`s to a non-null EMPTY range, shadowing the correct candidate in the `??` chain and sending `findByRangeAsync` to `first()` (the title). Fix = resume from `view.lastLocation.cfi` FIRST. + +**foliate `view.lastLocation` vs readerStore progress:** `view.lastLocation = {cfi, range, ...}` is set SYNCHRONOUSLY by foliate on every relocate; the readerStore progress is rAF-debounced (FoliateViewer `commitRelocate`) and lags/desyncs. For any resume/current-position logic prefer `view.lastLocation.cfi` (CFI is document-instance-independent, survives the section iframe being recreated on toggle — a stored `Range` does not). `view.lastLocation` is NOT in the FoliateView TS type by default (had to add it). + +**Verification/harness gotchas (claude-in-chrome on the reader):** synthetic keystrokes (`computer key shift+p`) BUFFER/DROP chaotically — single presses vanish then fire in delayed bursts, creating inconsistent multi-toggle states; `ArrowRight` repeat worked, single Shift+P often didn't. Menu clicks (View menu → Paragraph Mode) are reliable for toggling. The content iframe lives in the foliate-view shadow DOM (`iframeCount:0` at top level); focus on `FOLIATE-VIEW` → keydown arrives as an `iframe-keydown` postMessage, focus on parent → real window keydown. Probe live state via `document.querySelector('foliate-view').lastLocation`. Tool returns containing "overlay=" strings sometimes hit a `[BLOCKED: Cookie/query string data]` filter — return JSON objects instead. Related: [[tts-sync-chrome-verification]], [[tts-browser-e2e-harness]]. diff --git a/apps/readest-app/.claude/memory/pdf-scroll-mode-wheel-double-4727.md b/apps/readest-app/.claude/memory/pdf-scroll-mode-wheel-double-4727.md new file mode 100644 index 00000000..a2411faa --- /dev/null +++ b/apps/readest-app/.claude/memory/pdf-scroll-mode-wheel-double-4727.md @@ -0,0 +1,26 @@ +--- +name: pdf-scroll-mode-wheel-double-4727 +description: Fixed-layout/PDF scrolled mode scrolls 2x (instant lurch) when wheeling over the page vs smooth over the margin +metadata: + node_type: memory + type: project + originSessionId: 063f5588-52bf-4042-92f9-babcf492e378 +--- + +# PDF scrolled-mode wheel double-scroll (#4727) + +**Symptom:** In fixed-layout/PDF **scrolled** mode, a mouse-wheel notch scrolls ~2× as far and feels instant when the pointer is **over the page** (the iframe), but a single smooth scroll when over the **page margin**. Reproduces on BOTH web and tauri (reporter saw it only in the WebView2 app, but maintainer reproduced on web too). Paginated mode unaffected. + +**Root cause:** `fixed-layout.js` scroll mode (`#loadScrollPage`) attaches a `{ passive: true }` wheel listener to each page iframe's doc that called `this.scrollBy({ top: e.deltaY, behavior: 'instant' })`. The iframe is `scrolling="no"` + `overflow:hidden`, so the browser **already chains** the wheel to the host scroller natively (smooth). The manual `scrollBy` **stacks on top of** that native scroll → 2× distance, the instant jump = the `behavior:'instant'` part, the glide = the native chain. Margin-hover hits the host directly → only the single native scroll → no doubling. + +The iframe is interactive (`pointer-events:auto`) only during a 150ms idle window after scrolling settles (`#handleScrollEvent` disables it during active scroll, re-enables 150ms after). A notched wheel slower than ~6/sec puts EVERY notch in that idle window → every notch lands on the iframe → every notch doubles (explains the steady "twice as fast", not just the first tick). + +**Fix:** Delete the manual `this.scrollBy(...)`; keep `this.#setScrollIframeInteraction(false)` so the iframe stops intercepting and the rest of the gesture also scrolls the host natively. Native scroll-chaining is the single smooth scroll that matches the margin. The old "forward wheel to host" code wrongly assumed the tick was lost without it. + +**Why not preventDefault+manual:** would make page-hover an *instant* scroll, not matching the smooth native margin scroll the user wants. Letting native handle it is the only way to match the margin feel. + +**Reproduction / test technique (jsdom can't — needs real layout + real wheel):** +- Standalone Playwright proof: scroll container + `scrolling="no"` srcdoc iframe + the buggy handler, `page.mouse.wheel(0,120)` over the iframe → scrollTop 240 vs 120 over margin; remove `scrollBy` → 120 == 120. (real `mouse.wheel` triggers native chaining; synthetic dispatch does NOT.) +- Committed regression test `src/__tests__/document/fixed-layout-scroll-wheel.browser.test.ts` (browser lane, `pnpm test:browser`): mounts the REAL `` in scrolled mode (minimal fake book: `rendition.viewport`, sections whose `load()` returns `{ src:'srcdoc', data: tallHtml }` — `src` must be truthy or `#createScrollFrame` returns blank; `data` → srcdoc keeps iframe same-origin so contentDocument is reachable), dispatches a **synthetic** `WheelEvent` on the page iframe doc, asserts `renderer.scrollTop` stays 0 (synthetic wheel doesn't chain natively, so any movement is the JS handler = must be 0). Fails `120` against the bug, passes `0` fixed. + +Fix lives in the `packages/foliate-js` submodule (separate repo/commit). Relates to [[fixed-layout-paginated-scroll-reset-4683]], [[webtoon-mode-3647]]. diff --git a/apps/readest-app/.claude/memory/pdf-spread-canvas-seam-4587.md b/apps/readest-app/.claude/memory/pdf-spread-canvas-seam-4587.md new file mode 100644 index 00000000..7c1624df --- /dev/null +++ b/apps/readest-app/.claude/memory/pdf-spread-canvas-seam-4587.md @@ -0,0 +1,52 @@ +--- +name: pdf-spread-canvas-seam-4587 +description: PDF two-page spread shows a 1px white bar at the spine on fractional devicePixelRatio (Windows 150%); canvas bitmap truncation +metadata: + node_type: memory + type: project + originSessionId: 9c176878-7bcd-4411-8c55-5ebce094a73b +--- + +#4587 — PDF two-page spread shows a one-pixel white bar in the MIDDLE (at the +spine) on "certain zoom levels". Repro condition = fractional devicePixelRatio +(Windows display scale 150% → dpr 1.5); at 100% (dpr 1) no bar. Fixed in +`packages/foliate-js/pdf.js` `render()`. + +**Root cause:** `render()` sized the page canvas only via its bitmap +(`canvas.width = viewport.width`). `viewport.width = pageWidthCss * dpr` is +fractional, and a canvas bitmap width must be an integer, so it truncates (FP +error often drops a whole pixel: 522*1.5=783 → viewport 782.9999 → bitmap 782). +The iframe content is displayed scaled by `1/dpr` (the `documentElement` +`transform: scale(1/devicePixelRatio)`), so the truncated bitmap renders up to +~1 device px NARROWER than the page box. The left page's canvas stops short of +the spine → exposes the reader background as a thin seam (white in light +themes; in the dark demo it reads as a dark line). Right page's canvas starts +exactly at the spine, so the gap is the LEFT page's shortfall only. The element +flex boxes are always exactly adjacent (left.elR === right.elL === spine) — NOT +the source; the seam is canvas-vs-box, not box-vs-box. + +**Fix:** pin an explicit CSS size to the un-truncated viewport dims so the +bitmap scales to fill the box exactly: +`canvas.style.width = `${viewport.width}px``; same for height. Display = +viewport.width/dpr = exact page box → left canvas reaches the spine. General: +fixes every page-canvas edge shortfall (single page + right page outer edge +too), all dpr/modes. Idiomatic pdf.js HiDPI pattern (bitmap=device px, CSS= +logical size) that the foliate wrapper had omitted. + +**Why dpr=2 can't repro (and dpr=1.5 readily does):** equal-width spread pages +split content/2 exactly. At dpr 2, pageW*2 stays integer for even content +widths → clean. At dpr 1.5, pageW*1.5 = content*0.75 is fractional unless +content divisible by 4 → seam of 0, 0.5, or 1.0 device px depending on width. + +**CDP dpr=1.5 repro recipe (no device needed):** launch a throwaway desktop +Chrome `--force-device-scale-factor=1.5 --remote-debugging-port=9444 +--user-data-dir=/tmp/x`; dev-web seeds demo EPUBs in a fresh profile but no PDF +— import a sample PDF (`apps/readest-app/src/__tests__/fixtures/data/sample-alice.pdf`, +69pp US-Letter) via CDP `Page.setInterceptFileChooserDialog`+`fileChooserOpened` +→`DOM.setFileInputFiles` (the readest "Import Books" button opens a MENU; click +"From Local File" to trigger the chooser). `Browser.setWindowBounds` to sweep +ODD inner widths (1283/1284…) to hit fractional pageW. Measure left-page canvas +abs-right vs spine; capture a thin vertical clip at the spine to see the line. +Dev server picks up foliate-js edits on reload (HMR recompiled it; no restart +needed here, contra some older paginator notes). See [[issue-4112-scroll-anchoring]] +neighbors for other paginator/foliate fixes. diff --git a/apps/readest-app/.claude/memory/sync-synced-at-cursor-4678.md b/apps/readest-app/.claude/memory/sync-synced-at-cursor-4678.md new file mode 100644 index 00000000..0fd1d730 --- /dev/null +++ b/apps/readest-app/.claude/memory/sync-synced-at-cursor-4678.md @@ -0,0 +1,26 @@ +--- +name: sync-synced-at-cursor-4678 +description: "Decouple the incremental-pull cursor from updated_at via a server-stamped synced_at column on books (#4678)" +metadata: + node_type: memory + type: project + originSessionId: 5c738b55-09d2-42ea-8af0-ca13dfe2de6e +--- + +# Decouple sync pull cursor from updated_at — server `synced_at` (#4678, branch feat/sync-synced-at-cursor-4678) + +Follow-up cleanup to [[sync-statusless-book-rebump-4677]]. `books.updated_at` was overloaded: (1) incremental-pull cursor (`GET /api/sync?since=…` filters `updated_at>since`; device keeps one global `max(updated_at)`) AND (2) library "date read" sort key. A server-resolved merge (reading_status LWW #4634) had to be written `> every peer's global cursor` to propagate → forced `updated_at=now()` → reordered the date-read library by sync time. + +**Decision (user-chosen): server `synced_at`, books-only, koplugin untouched.** Scoped to `books` because it's the ONLY table with the overload — the only server-side `updated_at=now()` bumps are the books status-merge propagation (sync.ts) + progress piggyback writes books rows; configs/notes are never server-bumped and aren't a sort key; `stats.updated_at` is already server-assigned. (Confirmed both scope decisions via AskUserQuestion.) + +**Implementation:** +- Migration `docker/volumes/db/migrations/016_add_books_synced_at.sql` + baseline `docker/volumes/db/init/schema.sql`: add `synced_at timestamptz NOT NULL DEFAULT now()`; **backfill `= COALESCE(updated_at,created_at,now())` BEFORE creating the trigger** (else trigger clobbers backfill to now() → full re-sync storm); index `(user_id, synced_at)`; `BEFORE INSERT OR UPDATE` trigger `set_books_synced_at()` forces `NEW.synced_at = now()` (server-authoritative; clients never send it). First trigger on these tables — justified because synced_at must be server-stamped on EVERY write path (insert / client-wins update / status-merge / piggyback) and a trigger is DRY + unforgeable. +- `GET` (`src/pages/api/sync.ts` queryTables): books filters `.gt('synced_at', since)` + orders by synced_at; **drop the `deleted_at` clause for books** (a delete bumps synced_at). configs/notes unchanged (`updated_at`+`deleted_at`). +- `POST` status-merge: extracted pure `buildStatusPropagationRow(serverBook, status)` — grafts fresher status, **removed `updated_at: now()`**. Trigger advances synced_at so peers re-pull; updated_at stays = event time → no reorder. Progress piggyback unchanged (trigger now reliably propagates it too). +- Client `src/hooks/useSync.ts`: exported `computeMaxTimestamp`, keys on `synced_at` first, falls back to `max(updated_at,deleted_at)` when absent. Added `synced_at?: string|null` to `BookDataRecord` (`src/types/book.ts`); `Book.syncedAt` already existed (unused, left unpopulated). + +**Why backward-compatible (key insight):** `synced_at >= updated_at` always (backfill makes old rows equal, trigger makes new rows now() ≥ client event time), so `synced_at>since` is a strict SUPERSET of `updated_at>since`. Old web clients AND the koplugin keep working with no data loss — at worst a redundant re-pull of rare server-merged rows (idempotent upsert). **koplugin left unchanged**: its `last_books_pulled_at` is SHARED between pull-cursor (vs server) and push-delta detection (`getChangedBooks` vs local updated_at) — retargeting it to synced_at would need a risky pull/push cursor split (follow-up). koplugin advances books cursor from row `updated_at`/`deleted_at` (syncbooks.lua pullBooks), notes from `os.time()*1000` — both fine under the superset filter. + +**Tests** (test-first, pure units; no DB in unit tests): `__tests__/pages/api/sync-synced-at-cursor.test.ts` (buildStatusPropagationRow keeps updated_at), `__tests__/hooks/useSync-cursor.test.ts` (computeMaxTimestamp prefers synced_at, falls back). Full suite + lint + format:check green. No Lua/Rust changed. PR #4712. + +**Online-migration gotcha (prod books = 3.8M rows):** the naive `UPDATE … WHERE synced_at IS NULL` (one txn, all rows) DEADLOCKED against live `/api/sync` upserts (`40P01`, both lock books rows in opposite orders); `ALTER COLUMN SET NOT NULL` (full-table ACCESS EXCLUSIVE scan) + plain `CREATE INDEX` (write-blocking SHARE) compound it. Rewrote 016 as an ONLINE migration — **run via psql, NOT in a wrapping txn / NOT the Supabase dashboard editor** (uses `CREATE INDEX CONCURRENTLY` + a `CALL` proc that COMMITs per batch, both rejected inside a txn): (1) ADD COLUMN nullable + `SET DEFAULT now()` up front (inserts during backfill get now()); (2) batched backfill in a PROCEDURE — `WITH todo AS (SELECT ctid FROM books WHERE synced_at IS NULL LIMIT 10000 FOR UPDATE SKIP LOCKED) UPDATE … FROM todo`, `COMMIT` each batch, EXIT when no NULLs remain — **SKIP LOCKED never waits on an app-locked row** so no deadlock; (3) `CREATE INDEX CONCURRENTLY`; (4) trigger LAST (else it clobbers the backfill to now()); (5) hard NOT NULL dropped (default+trigger+backfill keep it populated, client falls back to updated_at) — optional `ADD CONSTRAINT … CHECK (synced_at IS NOT NULL) NOT VALID` then `VALIDATE` (lighter SHARE UPDATE EXCLUSIVE, no full AccessExclusive scan). Note `now()` is STABLE not VOLATILE → `ADD COLUMN … DEFAULT now()` is fast metadata-only (one value for all existing rows) but that = migration-time-now ≠ updated_at, which would force a full re-sync storm — hence the explicit updated_at backfill. COMMIT is allowed in a PROCEDURE-via-CALL but NOT in a DO block nor inside a `BEGIN…EXCEPTION` sub-block.