fix(rsvp): restore in-flow control bar layout reverted by #4589 (#4671)

* 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:
Huang Xin
2026-06-20 08:58:47 +08:00
committed by GitHub
parent 6e9faaa874
commit 23d1ef6f13
5 changed files with 147 additions and 52 deletions
@@ -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();