From 51fede1a0d2cc9a5de7ad6a2018586fc33c328f1 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 15 Jun 2026 03:18:07 +0800 Subject: [PATCH] fix(rsvp): keep the audio toggle from overlapping transport on mobile (#4585) The read-along audio toggle + settings gear sat in an `absolute end-0` cluster overlaid on the centered transport row. After the #3235 read-along feature grew that cluster from a single gear (~36px) to ~81px (audio + divider + gear), it covered the right end of the transport on narrow phones, hiding the audio button behind the "skip forward 15" control. Lay the playback controls out as a single full-width flex row: the audio toggle moves to the far left and the settings gear stays far right, symmetrically flanking the centered play button (justify-between on mobile, justify-center on md+). Tighten the secondary buttons on mobile (h-8, px-1.5) and add shrink-0 so the row fits without overlap; the symmetry keeps the play button centered. Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/rsvp-overlay-context.test.tsx | 29 +++++ .../reader/components/rsvp/RSVPOverlay.tsx | 104 +++++++++--------- 2 files changed, 82 insertions(+), 51 deletions(-) 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 e4eac9ae..b745d781 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 @@ -480,6 +480,35 @@ describe('RSVPOverlay — dictionary lookup (#4475)', () => { }); }); +describe('RSVPOverlay — audio toggle layout (#3235 mobile follow-up)', () => { + afterEach(() => cleanup()); + + const wordsState = () => + buildState({ words: [{ text: 'a', orpIndex: 0, pauseMultiplier: 1 }], currentIndex: 0 }); + + test('the audio toggle and settings flank the transport in flow, not an overlapping absolute cluster', () => { + const { container } = renderOverlay(wordsState()); + const audioBtn = container.querySelector('[aria-label="Play audio"]') as HTMLElement; + const settingsBtn = container.querySelector('[aria-label="Settings"]') as HTMLElement; + const playBtn = container.querySelector('[aria-label="Play"]') as HTMLElement; + expect(audioBtn).not.toBeNull(); + expect(settingsBtn).not.toBeNull(); + expect(playBtn).not.toBeNull(); + + // The audio toggle and settings gear must be siblings of the play button in + // the same flex row so the cluster never overlaps the centered transport on + // a narrow (mobile) viewport. + expect(audioBtn.parentElement).toBe(playBtn.parentElement); + expect(settingsBtn.parentElement).toBe(playBtn.parentElement); + + // No control may live inside an absolutely-positioned wrapper (the prior + // `absolute end-0` cluster that overlapped the transport on mobile). + expect(audioBtn.closest('.absolute')).toBeNull(); + expect(settingsBtn.closest('.absolute')).toBeNull(); + expect((playBtn.parentElement as HTMLElement).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 328276ed..91b6ffc0 100644 --- a/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx +++ b/apps/readest-app/src/app/reader/components/rsvp/RSVPOverlay.tsx @@ -1070,11 +1070,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. */} + + - {/* 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) */}