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) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-15 03:18:07 +08:00
committed by GitHub
parent b76e3a3718
commit 51fede1a0d
2 changed files with 82 additions and 51 deletions
@@ -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();
@@ -1070,11 +1070,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)')}
>
@@ -1084,7 +1114,7 @@ const RSVPOverlay: React.FC<RSVPOverlayProps> = ({
<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 md:h-9 md:w-9'
onClick={() => controller.decreaseSpeed()}
title={_('Slower (Left/Down)')}
>
@@ -1093,7 +1123,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 (,)')}
>
@@ -1103,7 +1133,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()}
@@ -1118,7 +1148,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 (.)')}
>
@@ -1127,7 +1157,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 md:h-9 md:w-9'
onClick={() => controller.increaseSpeed()}
title={_('Faster (Right/Up)')}
>
@@ -1136,7 +1166,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)')}
>
@@ -1144,48 +1174,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) */}