* fix(rsvp): restore in-flow control bar layout reverted by #4589 PR #4585 fixed the mobile RSVP control bar overlap by laying the audio toggle and settings gear in a single in-flow flex row flanking the centered transport. PR #4589 branched from main about five minutes before #4585 merged and merged about ten hours later without rebasing, so its squash carried the stale pre-#4585 file and reverted the entire fix, including the regression test #4585 had added. On narrow phones (360px) the audio and settings icons again overlapped the right end of the transport, hiding the "skip forward 15" control. Restore the #4585 layout and re-add a structural guard test asserting the audio toggle and settings share the transport row and live in no absolutely positioned cluster. Verified on a Xiaomi 13 (360px) via on-device CDP: no overlap, play button stays centered. Also stage the project-memory note for this regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(rsvp): hide Faster/Slower buttons at 350px or below On very narrow phones (width 350px or less) the control row has no room for every control. Collapse the Faster/Slower speed buttons via a max-[350px]:hidden variant (matching the existing 350px tightening tier) so the transport, audio toggle and settings never overflow. Speed stays adjustable from the WPM dropdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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
|
||||
|
||||
## 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/`<i>` 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<Document,...>` 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 `<p>`: `#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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<pid>` → 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]].
|
||||
@@ -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();
|
||||
|
||||
@@ -1077,11 +1077,41 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playback controls */}
|
||||
<div className='relative flex items-center justify-center gap-1 md:gap-2'>
|
||||
{/* 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. */}
|
||||
<div className='flex items-center justify-between md:justify-center md:gap-2'>
|
||||
{/* 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. */}
|
||||
<button
|
||||
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
|
||||
className={clsx(
|
||||
'touch-target flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95 md:h-9 md:w-9',
|
||||
ttsActive
|
||||
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
|
||||
: 'bg-transparent hover:bg-gray-500/20',
|
||||
)}
|
||||
onClick={() => onToggleTtsAudio?.()}
|
||||
title={ttsActive ? _('Pause audio') : _('Play audio')}
|
||||
>
|
||||
{ttsActive ? (
|
||||
<IoVolumeHigh
|
||||
className='h-4 w-4 md:h-5 md:w-5'
|
||||
style={{ color: accentColor }}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
) : (
|
||||
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label={_('Skip back 15 words')}
|
||||
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex shrink-0 cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-1.5 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-2'
|
||||
onClick={() => controller.skipBackward(15)}
|
||||
title={_('Back 15 words (Shift+Left)')}
|
||||
>
|
||||
@@ -1089,9 +1119,11 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
<IoPlaySkipBack className='h-5 w-5 md:h-6 md:w-6' />
|
||||
</button>
|
||||
|
||||
{/* Faster/Slower collapse below 350px (speed is still adjustable from
|
||||
the WPM dropdown) so the transport never overflows the row. */}
|
||||
<button
|
||||
aria-label={_('Decrease speed')}
|
||||
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 max-[350px]:hidden md:h-9 md:w-9'
|
||||
onClick={() => controller.decreaseSpeed()}
|
||||
title={_('Slower (Left/Down)')}
|
||||
>
|
||||
@@ -1100,7 +1132,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
<button
|
||||
aria-label={_('Previous word')}
|
||||
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9'
|
||||
onClick={() => controller.prevWord()}
|
||||
title={_('Previous word (,)')}
|
||||
>
|
||||
@@ -1110,7 +1142,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
<button
|
||||
aria-label={transportPlaying ? _('Pause') : _('Play')}
|
||||
className={clsx(
|
||||
'flex h-14 w-14 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
|
||||
'flex h-14 w-14 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-gray-500/15 transition-colors hover:bg-gray-500/25 active:scale-95 md:h-16 md:w-16',
|
||||
transportPlaying ? '' : 'ps-1',
|
||||
)}
|
||||
onClick={() => transportToggleRef.current()}
|
||||
@@ -1125,7 +1157,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
<button
|
||||
aria-label={_('Next word')}
|
||||
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9'
|
||||
onClick={() => controller.nextWord()}
|
||||
title={_('Next word (.)')}
|
||||
>
|
||||
@@ -1134,7 +1166,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
<button
|
||||
aria-label={_('Increase speed')}
|
||||
className='flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 max-[350px]:hidden md:h-9 md:w-9'
|
||||
onClick={() => controller.increaseSpeed()}
|
||||
title={_('Faster (Right/Up)')}
|
||||
>
|
||||
@@ -1143,7 +1175,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
|
||||
<button
|
||||
aria-label={_('Skip forward 15 words')}
|
||||
className='flex cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-2 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95'
|
||||
className='flex shrink-0 cursor-pointer items-center gap-0.5 rounded-full border-none bg-transparent px-1.5 py-1.5 transition-colors hover:bg-gray-500/20 active:scale-95 md:px-2'
|
||||
onClick={() => controller.skipForward(15)}
|
||||
title={_('Forward 15 words (Shift+Right)')}
|
||||
>
|
||||
@@ -1151,48 +1183,20 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
|
||||
<span className='text-xs font-semibold opacity-80'>15</span>
|
||||
</button>
|
||||
|
||||
{/* 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. */}
|
||||
<div className='absolute end-0 flex items-center gap-1'>
|
||||
<button
|
||||
aria-label={ttsActive ? _('Pause audio') : _('Play audio')}
|
||||
className={clsx(
|
||||
'touch-target flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none transition-colors active:scale-95',
|
||||
ttsActive
|
||||
? 'eink-bordered bg-[color-mix(in_srgb,var(--rsvp-accent)_18%,transparent)]'
|
||||
: 'bg-transparent hover:bg-gray-500/20',
|
||||
)}
|
||||
onClick={() => onToggleTtsAudio?.()}
|
||||
title={ttsActive ? _('Pause audio') : _('Play audio')}
|
||||
>
|
||||
{ttsActive ? (
|
||||
<IoVolumeHigh
|
||||
className='h-4 w-4 md:h-5 md:w-5'
|
||||
style={{ color: accentColor }}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
) : (
|
||||
<IoVolumeMediumOutline className='h-4 w-4 md:h-5 md:w-5' aria-hidden='true' />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className='h-5 w-px bg-gray-500/30' aria-hidden='true' />
|
||||
|
||||
<button
|
||||
aria-label={_('Settings')}
|
||||
className={clsx(
|
||||
'flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95',
|
||||
showSettings && 'bg-gray-500/15',
|
||||
)}
|
||||
onClick={() => setShowSettings((prev) => !prev)}
|
||||
title={_('Settings')}
|
||||
>
|
||||
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
|
||||
</button>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<button
|
||||
aria-label={_('Settings')}
|
||||
className={clsx(
|
||||
'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent transition-colors hover:bg-gray-500/20 active:scale-95 md:h-9 md:w-9',
|
||||
showSettings && 'bg-gray-500/15',
|
||||
)}
|
||||
onClick={() => setShowSettings((prev) => !prev)}
|
||||
title={_('Settings')}
|
||||
>
|
||||
<IoSettingsSharp className='h-4 w-4 md:h-5 md:w-5' />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings row (collapsible) */}
|
||||
|
||||
Reference in New Issue
Block a user