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

This commit is contained in:
Huang Xin
2026-06-23 00:52:39 +08:00
committed by GitHub
parent 664b6125a2
commit 787641b5b1
10 changed files with 293 additions and 0 deletions
@@ -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 `<foliate-fxl>` + 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=requiredoptional, `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 `<queries>` for `ACTION_PROCESS_TEXT` under targetSdk36 → dictionary apps invisible, only auto-visible browser returned (fix = add `<queries>` 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 `<monochrome>` 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 <file>`, 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 `<ruby cfi-skip>…<rt cfi-inert>` (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 = `<path>` 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 `</feed>`; Firefox DOMParser → `<parsererror>` (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)
@@ -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 `<monochrome>` layer**, so themed icons stopped
working. Fix = re-add `<monochrome><inset android:drawable="@mipmap/ic_launcher_monochrome" android:inset="22%"/></monochrome>`
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 `<monochrome>` in the XML + a
tracked monochrome mipmap per density). Related: [[dict-lookup-browser-hijack-4559]]
(Android resource/manifest gotchas), [[android-cdp-e2e-lane]].
@@ -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]].
@@ -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`, 85175%). 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 `<div>`, 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]].
@@ -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]].
@@ -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]].
@@ -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]].
@@ -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 `<foliate-fxl>` 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]].
@@ -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.
@@ -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.