Commit Graph

13 Commits

Author SHA1 Message Date
Huang Xin c8fabd331c fix(reader): resolve KOReader sync conflict against non-KOReader servers (#4205)
The sync-conflict dialog had two issues with servers other than KOReader
(e.g. Kavita's KOReader-compatible sync endpoint):

- "This device" preview rendered a bare "undefined" because reflowable
  books built the string from `sectionLabel`, which is empty for spine
  items with no matching TOC entry. It now falls back to the page count.
- Choosing "use remote" closed the dialog but never moved the reader:
  `applyRemoteProgress` only knew how to navigate via CREngine XPointers,
  so non-XPointer progress strings were silently ignored. It now falls
  back to `view.goToFraction` using the reported percentage.

Also fixes the section-title indentation in the dialog (SectionTitle
bakes in `ps-4`, which misaligned the labels against their values).

Closes #4200

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:22:34 +02:00
Huang Xin ad1c2d6bb0 fix(reader): filter Magic Mouse wheel events to stop accidental page turns (#4195)
A touch-surface mouse like the Magic Mouse emits a flood of tiny, low-
magnitude wheel events — plus an inertial momentum tail — for a single
physical gesture, and even a light brush of the surface produces spurious
deltas. The previous 100ms trailing debounce collapsed bursts but did not
filter by magnitude, so isolated micro-touches and the momentum tail each
turned a page, cascading into continuous accidental page turns in
paginated mode.

Add a wheel gesture detector that accumulates normalized wheel travel and
only flips once it crosses a deliberate-intent threshold, then swallows the
rest of the stream (the momentum tail) until the wheel goes idle — so one
physical gesture flips exactly one page, mirroring native readers.

Closes #4117

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:30:53 +02:00
Huang Xin 2d30868d23 fix(fonts): hydrate custom fonts on library page, closes #4178 (#4191)
Custom fonts vanished from the Font panel after an app restart unless a
book was opened first. The custom-font store is hydrated only by the
reader's FoliateViewer (on book open) or by useReplicaPull (gated on a
signed-in user), so opening Settings straight from the library left the
store empty.

Add a useCustomFonts hook that loads persisted custom fonts on mount,
unconditional of auth or book state, and mount it on the library page.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:19:45 +02:00
Roy Zhu fed8ab7b67 fix(tts): restore cross-section auto-page-turn during TTS playback (#4148)
When TTS playback crosses a section boundary, the page would stay on
the last page of the previous chapter while audio continued reading
the next chapter — leaving the user stuck behind the "back-to-TTS"
button.

Two compounding issues since the paginator adjacent-section preloading
landed:

1. `handleSectionChange` called `view.renderer.goTo(resolved)` without
   awaiting. `TTSController.#initTTSForSection` does
   `await this.onSectionChange?.(sectionIndex)` precisely so the view
   can finish navigating before audio of the new section starts, but
   the missing await defeated that contract.

2. `handleHighlightMark` returned silently on a cross-section
   mismatch (`viewSectionIndex !== ttsSectionIndex`), so when the
   renderer.goTo above completed only partially — which can happen on
   the new paginator when the target section is already loaded as an
   adjacent view and the post-goTo state appears reused without a
   visible page flip — there was no second chance to drag the view to
   the TTS cfi.

Fix:

- Await `view.renderer.goTo` in `handleSectionChange`.
- In `handleHighlightMark`, run the cross-section branch *before* the
  `followingTTSLocationRef` check and call `view.goTo(cfi)` directly,
  stamping `sectionChangingTimestampRef` so the back-to-TTS button
  stays suppressed while progress.location catches up. Skip only when
  the user is actively selecting text.

Adds unit tests covering both the cross-section navigation path and
the in-section scrollToAnchor path.
2026-05-13 16:44:10 +02:00
Huang Xin 1eae2af23e feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.

Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.

Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.

Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).

Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).

Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).

Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
2026-05-09 16:18:46 +02:00
Huang Xin 4110911011 fix(sync): keep dictionarySettings consistent across devices (#4105)
The bundled `settings` replica's `dictionarySettings.providerOrder`
and `providerEnabled` repeatedly drifted on multi-device setups: a
fresh-install Device B would overwrite Device A's authoritative
order with its own local default, dict tombstones referenced via
the settings replica left "skipped" gaps in the UI, and providerEnabled
keys missing from providerOrder rendered as silently lost imports.

Six related fixes (mostly orthogonal):

- **Disk-priming** in `initSettingsSync(initialSettings)`: seeds
  `lastPublishedFields` from the just-loaded disk settings so the
  first `setSettings(disk_default)` at boot diffs against the disk
  baseline (no diff → no push), instead of diffing every whitelisted
  field against `undefined` and clobbering the server with locals.
- **Settings boot pull is awaited first** in `useReplicaPull` (with
  a shared `settingsBootPullPromise`) so the dict/font/texture/opds
  pulls' auto-saves see server-primed `lastPublishedFields` rather
  than disk defaults — implicit even when the caller didn't request
  the `settings` kind.
- **Visibility / online / periodic auto-pull** in `useReplicaPull`:
  module-level listeners with a 30s visibility throttle and a 5-min
  interval keep long-lived foreground tabs in sync (previously the
  hook only did the once-per-session boot pull and `ReplicaSyncManager.startAutoSync`'s
  comments lied — it only flushed dirty pushes).
- **Tombstone scrubbing for no-local rows**: `softDeleteByContentId`
  scrubs `providerOrder` / `providerEnabled` by contentId regardless
  of whether a local dict matches, and `applyRow` always invokes it
  on tombstones — so Device B fresh-installs that pulled tombstoned
  contentIds via the settings replica without ever having a local row
  still get the provider-side entries cleaned.
- **Orphan rescue** in `loadCustomDictionaries`: providerEnabled keys
  that have no slot in providerOrder (per-field LWW splits a settings
  push) get spliced before the first builtin so user-imported dicts
  stay contiguous near the top of the list, not stranded after the
  builtins where users miss them.
- **`addDictionary` prepends** to `providerOrder` so a fresh local
  import shows up at the top of the list. Reviving a soft-deleted
  entry preserves its existing slot.
- **Explicit-publish gate for `providerOrder`**: `markExplicitProviderOrderPublish()`
  in `replicaSettingsSync` is the only way for `publishSettingsIfChanged`
  to ship `dictionarySettings.providerOrder`. UI handlers that
  intentionally reorder (drag-drop, dict import, dict delete,
  web-search add) opt in via `saveCustomDictionaries(env, { publishOrderChange: true })`.
  Auto-mutations from replica pull / orphan-rescue / tombstone-scrub
  no longer ever republish the local view of order.

12 new tests across `replicaSettingsSync`, `replicaPullAndApply`,
`useReplicaPull`, and `customDictionaryStore`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:40:19 +02:00
Huang Xin 712d564e9d feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* feat(sync): encrypt opds_catalog credentials end-to-end (TS path)

Wires encrypted-credential sync for opds_catalog via the CryptoSession
shipped in PR 4a (#4084) plus a new publish/pull crypto middleware.
TS-only — native still uses ephemeral storage (re-enter passphrase per
launch); PR 4d wires the OS keychain.

- ReplicaAdapter gains optional `encryptedFields: readonly string[]`.
  Adapters stay sync; the middleware handles the crypto round trip.
- replicaCryptoMiddleware.ts: encryptPackedFields drops the named
  fields from the push when the session is locked (no plaintext
  leak); decryptRowFields drops them on pull failure (local
  plaintext preserved by the store merge).
- replicaPublish / replicaPullAndApply invoke the middleware.
- OPDS adapter declares encryptedFields = [username, password] and
  now pack/unpack them as plaintext.
- passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent
  calls, prompts via the registered prompter with kind=setup|unlock,
  throws NO_PASSPHRASE on cancel.
- PassphrasePromptModal mounted at the Providers root; registers
  itself as the gate prompter.
- CryptoSession.forget() wipes server-side envelopes + salts.
- Migration 010 + replica_keys_forget RPC; DELETE
  /api/sync/replica-keys + client wrapper.
- SyncPassphraseSection on the user page: status / Set / Unlock /
  Lock / Forgot.
- CatalogManager pre-save: ensurePassphraseUnlocked when credentials
  are present; user cancel saves locally without sync.

Plan updated: PR 4 split documented as 4a/4b/4c/4d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): persist sync passphrase via OS keychain (Tauri)

Replaces the EphemeralPassphraseStore stub on native with real
OS-keychain storage so users don't re-enter their sync passphrase
every launch. Web stays on the in-memory ephemeral store by design.

Native bridge plugin gains 4 commands wired across all platforms:

- Rust desktop (`keyring` crate): macOS Keychain on apple-native,
  Windows Credential Manager on windows-native, Linux libsecret/
  Secret Service on sync-secret-service. Per-target features so each
  platform compiles only the backend it needs.
- iOS Swift: Security framework Keychain (kSecClassGenericPassword,
  SecItemAdd / Copy / Delete).
- Android Kotlin: androidx.security EncryptedSharedPreferences
  (AndroidKeystore-derived AES-GCM master key,
  AES256_SIV / AES256_GCM key/value encryption).

TS layer:

- TauriPassphraseStore wraps the bridge calls. set is fail-loud
  (surfaces keychain rejection); get is fail-soft (returns null on
  any error so the gate prompts).
- createPassphraseStore returns ephemeral synchronously;
  upgradeToKeychainIfAvailable swaps the singleton to
  TauriPassphraseStore on Tauri after probing the bridge. CryptoSession
  resolves the store via createPassphraseStore() each touch so the
  swap is transparent.
- CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale-
  entry recovery clears the store when the account has no salt
  server-side. unlock/setup persist; forget also clears the store.
- Providers boot effect: upgrade keychain → silent restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): make encrypted-credential pull actually decrypt + UX polish

PR 4c shipped the encrypt path but the pull side silently dropped
ciphers when locked, the modal was busy with double-rings, and web
re-prompted on every page refresh. This rolls up the post-test
fixes + UX polish:

Pull-side decrypt:
- decryptRowFields takes an `onLocked` callback the orchestrator
  wires to the passphrase gate; encountering a cipher field with a
  locked session now triggers the lazy-prompt path instead of
  dropping the field.
- replicaPullAndApply re-applies the unpacked row for metadata-only
  kinds even when a local copy exists, so the now-decrypted creds
  reach the store (the binary-kind skip-if-local optimization
  doesn't apply).
- Cipher fingerprint comparison: capture the row's `cipher.c` for
  each encrypted field, compare against the local record's
  lastSeenCipher. Same → skip prompt + decrypt entirely. Different
  (rotation / value change on another device) → prompt to
  re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher.

Web persistence:
- SessionStoragePassphraseStore: passphrase survives page refresh
  within the same tab, dies on tab close. Replaces
  EphemeralPassphraseStore as the default on web. Avoids
  localStorage / IndexedDB to keep the tab-scoped trust boundary.

UI:
- Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled
  input style with single subtle focus border, btn-primary +
  btn-ghost replaced with leaner custom buttons. eink-bordered +
  btn-primary classes give the dialog correct e-paper rendering.
- globals.css: suppress redundant outline/box-shadow on focused text
  inputs / textareas (the element's own border is the focus
  indicator).
- AGENTS.md: documents the e-ink convention (`eink-bordered`,
  `btn-primary` for inverted CTAs, etc.) so future widgets ship with
  e-paper support.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 13:22:49 +02:00
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00
Lex Moulton 030a7c0823 perf: optimize library operations for large collections (#3827)
* perf(store): decouple page turn from full library rewrite for large collections

Previously every page turn triggered setLibrary() which copied the entire
library array, ran refreshGroups() with MD5 hashing over all books, and
caused cascading re-renders. With ~2800 books this made reading unusable.

- Add hash-indexed Map to libraryStore for O(1) book lookups
- Add lightweight updateBookProgress() that skips array copy and refreshGroups
- Use hash index in setProgress, saveConfig, and initViewState
- Batch cover URL generation with concurrency limit on library load

Addresses #3714

* perf(import): replace filter()[0] with find() to short-circuit on first match

* fix(store): replace Object.assign state mutation with immutable spread in setConfig

* perf(persistence): remove JSON pretty-printing to reduce serialization overhead

* fix(reader): stabilize debounce reference in useIframeEvents to prevent timer reset on re-render

* perf(context): memoize provider values to prevent unnecessary consumer re-renders

* perf(store): cache visible library to avoid refiltering on every access

* perf(library): remove redundant refreshGroups call already triggered by setLibrary

* perf(import): replace O(n) splice(0,0) with O(1) push for new book insertion

* perf(import): defer library persistence to end of import batch instead of every 4 books

* perf(library): skip full library reload on reader close since store is already in sync

* fix: address PR review feedback for library perf optimizations

Correctness fixes for issues found in code review:

- fix(library): restore library reload on close-reader-window. Reader
  windows are independent Tauri webviews with their own libraryStore
  instance, so progress / readingStatus / move-to-front updates from
  the reader do not propagate to the main window. Reload from disk
  so the library reflects the changes the reader just persisted.

- perf(import): wire BookLookupIndex into importBooks. The lookupIndex
  parameter on bookService.importBook had no caller, leaving the
  Map-based dedup path dead. Build the index once per import batch
  in app/library/page.tsx and thread it through appService.importBook
  so the O(1) dedup path is actually exercised.

- perf(import): defer library save to end of batch. Add a skipSave
  option to libraryStore.updateBooks and call appService.saveLibraryBooks
  once after the entire import loop, instead of once per concurrency-4
  sub-batch.

- fix(store): make updateBookProgress immutable. The previous in-place
  mutation reused the same library array reference, bypassing Zustand
  change detection AND leaving the visibleLibrary cache holding stale
  Book references. Now slice the array, update the entry, and refresh
  visibleLibrary. Also make readingStatus a required parameter so
  future callers cannot accidentally clear it by omitting the argument.

- fix(store): make saveConfig immutable. It previously mutated the Book
  object's progress / timestamps in place and used splice/unshift on
  the shared library array. Now spread to a new book object and rebuild
  via setLibrary. Also corrects the interface signature to return
  Promise<void> (the implementation was already async).

- fix(store): make updateBook immutable for the same reason — it was
  mutating the previous-state library array before spreading.

- fix(context): wrap AuthContext login/logout/refresh in useCallback.
  Without this, the useMemo deps array changed every render and the
  memo was a no-op, defeating the optimization the PR was trying to
  add.

- fix(reader): use a ref for handlePageFlip in useMouseEvent's debounce.
  The empty-deps useMemo froze the first-render handler; with the ref
  the debounced wrapper always invokes the latest closure.

Test coverage added:
- library-store: immutable updateBookProgress, visibleLibrary cache
  refresh, deleted-book filtering, updateBooks skipSave option
- book-data-store: immutable saveConfig, move-to-front correctness,
  visibleLibrary order, persistence behavior
- import-metahash: BookLookupIndex update on new import, lookup-index
  consultation before scanning books array
- auth-context (new file): context value identity stability across
  re-renders, callback identity stability
- useIframeEvents (new file): debounced wheel handler dispatches to
  the latest handlePageFlip after re-render

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(types): move BookLookupIndex to types/book.ts

Avoids the inline `import('@/services/bookService').BookLookupIndex`
type annotation in types/system.ts. Both the AppService interface and
the bookService implementation now import BookLookupIndex from the
canonical location alongside Book.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(import): convert importBook params to options object

Replace the long positional-argument list on appService.importBook
(saveBook, saveCover, overwrite, transient, lookupIndex) with a
single options object so callers no longer need to pad with
`undefined, undefined, undefined, undefined` to reach the parameter
they actually want to set.

Before:
  await appService.importBook(file, library, undefined, undefined,
                              undefined, undefined, lookupIndex);

After:
  await appService.importBook(file, library, { lookupIndex });

The underlying bookService.importBook is also refactored to take an
options object: required AppService callbacks (saveBookConfig,
generateCoverImageUrl) are bundled with the optional flags via an
ImportBookInternalOptions interface that extends the public
ImportBookOptions defined in types/book.ts.

All existing call sites updated to the new shape.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:32:35 +02:00
Huang Xin b679817fce fix(tts): prevent double playback on rapid TTS icon clicks (#3764)
Guard handleTTSSpeak with a single-flight ref so a second tts-speak
event that arrives while the first is still inside its initial awaits
(initMediaSession / backgroundAudio / TTSController.init) is ignored
instead of racing ahead to construct a second TTSController. Without
this, rapid clicks produced two concurrent speakers talking over each
other because viewState.ttsEnabled is only set at the end of the first
invocation, so the footer bar would dispatch tts-speak twice.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:11:28 +02:00
Huang Xin 70b94d8986 fix(layout): fixed layout of progress bar in vertical mode (#3749) 2026-04-04 21:20:34 +02:00
Huang Xin f361698e05 feat(android): add D-pad navigation for Android TV remote controller (#3745)
* feat(android): add D-pad navigation for Android TV remote controller

Add basic D-pad/arrow key navigation support for Android TV Bluetooth
remote controllers, covering the full flow: library grid browsing,
reader page turning, toolbar interaction, and TTS toggle.

Library:
- Custom spatial navigation hook for grid D-pad navigation
- Arrow keys move between BookshelfItem elements with auto column detection
- ArrowDown from header enters the bookshelf grid

Reader:
- Enter key toggles header/footer toolbar visibility
- Left/Right navigate between toolbar buttons, Up/Down moves between
  header and footer bars
- Back button dismisses toolbar (with blur) before sidebar/library
- Focus-probe technique for reliable button visibility detection
  across fixed/hidden containers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover

Track pointer activity to distinguish keyboard vs mouse toolbar activation.
When the toolbar appears due to mouse hover, skip auto-focus to prevent
unwanted focus outlines on desktop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: track keyboard events instead of pointer events for auto-focus guard

Pointer events from iframes don't bubble to the main document, so
tracking pointermove/pointerdown missed hover interactions over book
content. Track keydown instead — auto-focus only when a recent keyboard
event (Enter key) triggered the toolbar, not mouse hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:50:34 +02:00