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) */}