diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index d996c0e4..6fef89de 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -50,6 +50,7 @@ - [TTS sync paragraph+RSVP (#3235, PR #4576)](tts-sync-paragraph-rsvp-3235.md) — TTS-is-clock follow: canonical `tts-position{cfi,kind:word|sentence,sectionIndex,sequence}`; in-mode 🔊 audio toggle (`build{Paragraph,Rsvp}TtsSpeakDetail`, live-range gate); **current word/sentence highlight painted on the overlay CLONE via CSS Custom Highlight API** (no DOM mutation, spans inline; offsets relative to para-start map 1:1 to clone, `getTextSubRange` reuse, index-tagged vs stale); kind-gating `decideParagraphTtsHighlight` (Edge word wins, skip coarse sentence); `::highlight()` from `ttsHighlightOptions` ## Build & Vendoring +- [fastlane Apple App Store submission](fastlane-apple-appstore-submission.md) — `release_ios`/`release_macos` lanes (App Store review + TestFlight on the altool-uploaded build); gotchas = Tauri auto-notarizes if `APPLE_API_KEY_PATH` is in the macOS build env (keep it OUT, derive from key id), fastlane runs lanes from `./fastlane` so anchor paths via `repo_path`, npm-dotenv-cli vs Ruby-gem shadowing + `cd ../..` - [Turbopack build-cache OOM + gated Docker standalone (#4619)](turbopack-build-cache-oom-docker-standalone.md) — interrupted-build partial `turbopackFileSystemCacheForBuild` cache → 42 workers/18GB-swap freeze (clean build=~6.5GB); disabled the flag; `output:'standalone'` gated on `BUILD_STANDALONE` (Docker-only); tauri CI uses `next dev` (config-independent) - [Deps/security override workflow](deps-security-overrides-workflow.md) — fix transitive npm Dependabot alerts: main monorepo overrides live in `pnpm-workspace.yaml` (NOT root package.json); `packages/tauri-plugins` is a SEPARATE submodule project w/ own lockfile + `minimumReleaseAge` (main workspace has no age gate); bound 0.x overrides like `vite`; verify via test+lint+build-web. PR #4618 (esbuild 0.28.1, vitest 4.1.9) - [R2 rclone CreateBucket 403 (#4588)](r2-rclone-createbucket-403.md) — single-file `rclone copyto`/`moveto` probes CreateBucket → 403 on object-scoped R2 token; use a directory `rclone copy` (or `no_check_bucket=true`); broke nightly assemble, not the release flow @@ -63,6 +64,7 @@ - [Android Open-with intent flow (#4521)](android-open-with-intent-flow.md) — "Open with"/"Send to" pipeline: `NativeBridgePlugin.kt::handleIntent` → `shared-intent` → `useAppUrlIngress` → `useOpenWithBooks` (VIEW=transient→reader, SEND=library+upload). Telegram fails where file-manager works on TWO axes: cold-start delivery (fixed by #4527, on dev NOT released v0.11.4) + foreign-private-file read (Telegram FileProvider non-persistable grant vs shared-storage FUSE real-path). adb MediaStore VIEW repro tests pipeline but CANNOT reproduce the read axis (MANAGE_EXTERNAL_STORAGE bypasses grant) - [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 ## Feature Notes - [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) @@ -92,6 +94,8 @@ - [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls ## Reader UI Fixes +- [RSVP control bar overlap = REVERT](rsvp-control-bar-overlap-revert.md) — mobile RSVP TTS+settings overlapping transport was a REGRESSION: #4585 fixed it (in-flow `justify-between md:justify-center`), stale-branch #4589 (Word Wise) squash-reverted the whole fix incl. its guard test; re-fixed + on-device CDP recipe (Shift+V to enter RSVP, live-DOM preview) +- [Overlay z-index scale](zindex-overlay-scale.md) — compact global scale (RSVP 100/101, Settings 110, ModalPortal/CmdPalette 120, toast 130, app-lock 200) replacing insane 10000s; Add-Catalog-behind-Settings was MOBILE-ONLY (desktop `.window-border` z-99 traps inline Settings; ModalPortal portals to body→wins); static invariant test `zIndexScale.test.ts`; on-device CDP verify via `pnpm dev-android` devtools build - [Search excerpt no context for styled words (#4594)](search-excerpt-context-4594.md) — RESOLVED (foliate-js#25 + readest#4631). italic/`` word = own `strs[]` text node; `makeExcerpt` read context only WITHIN the node → empty pre/post; fix = `collectBefore/After` walk neighbour nodes (+2 latent multi-node match bugs: string-index `slice`, `start===end`) - [Global annotation page-turn lag (#4575)](global-annotation-pageturn-perf-4575.md) — highlighting recurring names = `global` highlights re-fanned-out (TreeWalker + getCFI/occurrence + SVG churn) EVERY page turn (~25-45ms desktop, ×mobile); fix = `WeakMap` memo in `globalAnnotations.ts` skips already-expanded sections; live-profiled via dev-web foliate-view; GBK-TXT synthetic-drop import recipe - [Overlayer splitRange by text nodes](overlayer-splitrange-textnodes.md) — highlight SVG missed bullet-list text when range also touched a `

`: `#splitRangeByParagraph`'s `'p,h1-h4'` selector dropped `li` (3rd whack-a-mole after f087826/920676b); fix = walk text nodes + `img,svg` in overlayer.js, never block-tag selectors; jsdom test stubs `Range.prototype.getClientRects` diff --git a/apps/readest-app/.claude/memory/pdf-oom-range-flood-3470.md b/apps/readest-app/.claude/memory/pdf-oom-range-flood-3470.md index 4aa9797a..18b4eefb 100644 --- a/apps/readest-app/.claude/memory/pdf-oom-range-flood-3470.md +++ b/apps/readest-app/.claude/memory/pdf-oom-range-flood-3470.md @@ -26,7 +26,7 @@ connections/host**; the custom `rangefile` (and iOS native-file) scheme has no such cap. Explains "50 MB+" (bigger PDF → more scattered objects → bigger flood) and "crashes on some devices only" (heap/WebView threshold). -**Fix (PR TODO):** `packages/foliate-js/pdf.js` `makePDF` — queue + pump bounding +**Fix (RESOLVED — foliate-js#31 squash `e098bc3` + readest#4670, both merged):** `packages/foliate-js/pdf.js` `makePDF` — queue + pump bounding range reads to `MAX_CONCURRENT_RANGES = 6` (mimics the browser's per-host limit). One spot covers Android `RemoteFile`, iOS `NativeFile`, web `File`. Throttling is **free** on speed (6 parallel fetches saturate throughput). foliate-js diff --git a/apps/readest-app/.claude/memory/rsvp-control-bar-overlap-revert.md b/apps/readest-app/.claude/memory/rsvp-control-bar-overlap-revert.md new file mode 100644 index 00000000..63830289 --- /dev/null +++ b/apps/readest-app/.claude/memory/rsvp-control-bar-overlap-revert.md @@ -0,0 +1,18 @@ +--- +name: rsvp-control-bar-overlap-revert +description: RSVP mobile control bar overlap was a REGRESSION — PR +metadata: + node_type: memory + type: project + originSessionId: cc658a96-fce5-4922-b924-361173c57e2a +--- + +RSVP (Speed Reading) overlay control bar: on narrow phones (Xiaomi 13 = 360px CSS width) the audio (TTS 🔊) toggle + settings ⚙ gear overlapped the right end of the centered transport row, hiding the "skip forward 15" label. + +**This was a regression, not a new bug.** PR #4585 (`51fede1a0`, merged 2026-06-14 19:18Z) already fixed it: replaced the `absolute end-0` cluster with a single in-flow `flex items-center justify-between md:justify-center` row — audio toggle far-left, settings far-right, flanking the centered play button; secondary buttons tightened to `h-8 w-8 shrink-0 md:h-9 md:w-9`, skip buttons `px-1.5 md:px-2`. At 360px the 9 controls pack ~340px into a 336px row with zero gaps but **no overlap** (verified on-device). + +**Reverted by PR #4589** (`490824504`, `feat/word-wise`, Word Wise inline vocab). Branch created 19:13Z — 5 min BEFORE #4585 merged — and merged ~10.5h later WITHOUT rebasing. The squash carried the stale pre-#4585 `RSVPOverlay.tsx`, so its diff is an exact mirror revert: #4585 = +53/−51 src & +29 test; #4589 = +51/−53 src & −29 test. #4589 added ZERO Word Wise code to RSVPOverlay — those hunks were purely the revert. It also deleted #4585's guard test (`audioBtn.closest('.absolute')).toBeNull()`), so CI couldn't catch the reintroduced overlap. + +**Re-fix (this session):** restored the #4585 block byte-for-byte + re-added a guard test (`audio/settings share the play button's parent`, parent `className` has no `absolute`) in `rsvp-overlay-context.test.tsx`. Pattern to watch: a stale feature branch squash-merged after an intervening fix silently reverts it — see [[security-advisories-web-2026-06]] (shared-target worktree build-cache pollution) for the same stale-branch family. + +**On-device verify recipe:** app running → `adb forward tcp:9222 localabstract:webview_devtools_remote_` → enter RSVP by dispatching synthetic `KeyboardEvent('keydown',{key:'v',shiftKey:true})` on `window` (Shift+V shortcut, listener on window; blur activeElement first) → click "From Current Page" → CDP-mutate the live DOM to preview a layout fix before rebuilding (apply exact source classes + reparent, then `getBoundingClientRect` overlap check + `adb exec-out screencap`). See [[cdp-android-webview-profiling]]. diff --git a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx index 0c6485e5..1f3cd7f8 100644 --- a/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx +++ b/apps/readest-app/src/__tests__/components/rsvp-overlay-context.test.tsx @@ -529,6 +529,75 @@ describe('RSVPOverlay — dictionary lookup (#4475)', () => { }); }); +describe('RSVPOverlay — playback control layout (#4585, regressed by #4589)', () => { + afterEach(() => cleanup()); + + // The audio (TTS) toggle and the settings gear must sit in the SAME flex row + // as the transport buttons, flanking the centered play button in normal flow. + // The earlier `absolute end-0` cluster overlaid them on top of the right end + // of the transport, hiding the audio button behind "skip forward 15" on narrow + // phones. Keeping all three as siblings of the play button is what prevents the + // overlap, so assert the structure here (jsdom can't measure the overlap). + test('audio toggle and settings flank the transport in the same flex row', () => { + const state = buildState({ + words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }], + currentIndex: 0, + }); + const { container } = renderOverlay(state); + + const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement; + const settingsButton = container.querySelector('[aria-label="Settings"]') as HTMLElement; + const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement; + expect(audioButton).not.toBeNull(); + expect(settingsButton).not.toBeNull(); + expect(playButton).not.toBeNull(); + + // All three share the play button's parent (the single flex row) — the audio + // toggle and settings are not tucked into a separate absolute cluster. + expect(audioButton.parentElement).toBe(playButton.parentElement); + expect(settingsButton.parentElement).toBe(playButton.parentElement); + }); + + // On very narrow phones (< 350px) the row has no room for every control, so + // the Faster/Slower speed buttons collapse to save space (speed is still + // adjustable from the WPM dropdown). The core transport stays put. + test('hides the Faster/Slower buttons below 350px to save space', () => { + const state = buildState({ + words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }], + currentIndex: 0, + }); + const { container } = renderOverlay(state); + + const decrease = container.querySelector('[aria-label="Decrease speed"]') as HTMLElement; + const increase = container.querySelector('[aria-label="Increase speed"]') as HTMLElement; + const play = container.querySelector('[aria-label="Play"]') as HTMLElement; + const audio = container.querySelector('[aria-label="Play audio"]') as HTMLElement; + const settings = container.querySelector('[aria-label="Settings"]') as HTMLElement; + + expect(decrease.className).toContain('max-[350px]:hidden'); + expect(increase.className).toContain('max-[350px]:hidden'); + // Transport, audio toggle and settings must remain visible at any width. + expect(play.className).not.toContain('max-[350px]:hidden'); + expect(audio.className).not.toContain('max-[350px]:hidden'); + expect(settings.className).not.toContain('max-[350px]:hidden'); + }); + + // The previous fix relied on absolute positioning being gone; guard against it + // sneaking back into the row that holds the transport controls. + test('the playback control row is not absolutely positioned', () => { + const state = buildState({ + words: [{ text: 'hello', orpIndex: 1, pauseMultiplier: 1 }], + currentIndex: 0, + }); + const { container } = renderOverlay(state); + + const playButton = container.querySelector('[aria-label="Play"]') as HTMLElement; + const audioButton = container.querySelector('[aria-label="Play audio"]') as HTMLElement; + expect(playButton.parentElement!.className).not.toContain('absolute'); + expect(audioButton.parentElement!.className).not.toContain('absolute'); + }); +}); + describe('RSVPOverlay — start delay setting (#4478)', () => { afterEach(() => { cleanup(); diff --git a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx index 05faf760..cc77282d 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -1077,11 +1077,41 @@ const RSVPOverlay: React.FC = ({ - {/* Playback controls */} -

+ {/* Playback controls — a single full-width flex row on mobile so the + audio toggle (far left) and settings gear (far right) flank the + centered transport in normal flow instead of overlapping it from an + absolute corner; a centered cluster on md+ where there is room. */} +
+ {/* Audio (TTS) read-along toggle — starts TTS from the displayed word, + or stops it when engaged (decision 5, #3235). Far-left peer of the + transport so the centered play button stays centered and nothing + overlaps on mobile. Active state uses a filled glyph + eink-bordered + surface so it reads in e-ink without relying on color. */} + + + {/* Faster/Slower collapse below 350px (speed is still adjustable from + the WPM dropdown) so the transport never overflows the row. */} - {/* Trailing cluster: audio (TTS) toggle + divider + settings gear. - The audio toggle starts TTS from the displayed word (or stops it - when engaged) — never a second play triangle (decision 5). Active - state uses a filled glyph + eink-bordered surface so it reads in - e-ink without relying on color. */} -
- - -
+ {/* Settings — far-right peer mirroring the audio toggle on the left, + so both flank the centered transport in normal flow (decision 5, + #3235) without an absolute cluster overlapping it on mobile. */} +
{/* Settings row (collapsible) */}