fix(player): reliable Escape "Back" + resumable, optionally-confirmed song exit (#619)

* fix(player): make Escape a reliable Back; resumable + optionally-confirmed song exit

Escape didn't always leave a song: clicking a transport control (play/FF/RW/
restart) left that <button> focused, and _shortcutDispatchBlocked() bails the
shortcut dispatcher for any focused INPUT/SELECT/TEXTAREA/BUTTON — so the
player-scope Escape=Back shortcut never fired until the user clicked empty
canvas to blur the control. Space already had a player-screen carve-out (#593);
Escape did not. That asymmetry was the bug.

Phase 1 — focus fix: generalize the Space carve-out in _shortcutDispatchBlocked
to Escape, on the player AND settings screens (both register Escape=Back;
settings had the identical latent bug). The earlier guards still win: text
inputs are exempted first, the Section Practice popover already claims Escape,
and a true modal (role=dialog aria-modal=true / .feedBack-modal) still traps it.
Plugins' player-scope Escape shortcuts are fixed identically.

Phase 2 — resume: leaving the player snapshots {song, arrangement, position,
speed} to localStorage; a non-blocking "Resume practice" pill offers it back on
the next non-player screen / next launch. playSong() gains a {resume} option
that restores speed + seeks to the saved position on song:ready instead of the
normal autostart. Conservative (ignores <3s / near-end), cleared on natural
song-end and once consumed, expires after 24h.

Phase 3 — opt-in "Ask before leaving a song" (Gameplay tab, default OFF). A
true-modal confirm with monotonic Escape (the second Escape leaves) and
Space/Enter = Leave. The player Escape shortcut and the v3 close button route
through window.requestExitSong(); auto-exit on song-end and a results screen's
own Close stay unguarded.

Design rationale: a multi-seat design charrette (engagement, learning-design,
operability, codebase-reality) — leaving a song should be reliable and
recoverable, not gated; the confirm is opt-in only.

Tests: tests/browser/{keyboard-shortcuts,resume-session,exit-confirm}.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* test(browser): suppress first-run onboarding in keyboard/resume/exit specs

The first-run onboarding overlay (#v3-onboarding) is a modal that intercepts
pointer/keyboard events; on a fresh profile it covers the player and breaks any
test that presses Escape or clicks. Stub GET /api/profile to an onboarded
profile in each beforeEach so the app behaves like a returning user (the state
these tests assume).

Also tighten the Section Practice Escape test to assert the guarantee the fix
actually provides — Escape does not exit the song while the popover is open (the
line-447 guard wins over the carve-out) — rather than asserting the popover's
own close handler fires, which isn't wired for a synthetic bar.

Verified locally against a worktree server (Chromium): all 16 new specs pass
(5 Escape + 6 resume + 5 exit-confirm) plus the existing #593 Space tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): exit-confirm — Escape cancels back to song, pause on open/resume on stay

Refinements from tester feedback on the exit-confirm (default stays OFF):

- Escape on the open prompt now = Stay (dismiss + return to the song), matching
  every other modal and the generic _confirmDialog (Esc=cancel). A second
  Escape therefore returns to the song instead of leaving it. Leaving stays the
  explicit, default-focused "Leave" button, so Space/Enter/click = "just get me
  out" (the OP's "Space always hits leave").
- Opening the prompt PAUSES the song (via the canonical togglePlay path, HTML5
  + _juceMode) so it isn't running/being scored behind the modal; Stay resumes
  exactly what we paused. Guards: cancel any count-in on open; resume only if we
  paused (wasPlaying), only if still the same live song on the player
  (_audioSeekGen unchanged), and never auto-resume a song the user had paused.
- Trap Tab inside the dialog; backdrop click was already Stay.

Specs: exit-confirm.spec.ts updated — the monotonic "second Escape leaves" test
becomes "second Escape stays", plus a backdrop-click-stays test. The audio
pause/resume itself is verified manually on web + desktop (the mock song has no
backing track); these specs lock the navigation + keyboard semantics.

NOTE: the pause/resume adds a new pause→resume cycle on the desktop JUCE
transport (known play/pause-desync path) — smoke-test on the desktop build
before merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(player): accurate exit-confirm copy + keep resume snapshot on failed load

Two review follow-ups on the Escape/resume/confirm work:

- Settings copy said "a second Escape (or Space/Enter) still leaves",
  but Escape dismisses the confirm (Stay) like every other modal — only
  Space/Enter/Leave exit. Corrected the Gameplay-tab description so it
  matches the implementation (and the committed exit-confirm specs).

- resumeLastSession() cleared the snapshot BEFORE awaiting playSong(), so
  a transient load/connect failure permanently lost the Resume pill with
  no retry. Clear only after the load resolves; on failure keep the
  snapshot (and drop the pending in-memory resume) so the pill re-offers
  it on the next non-player screen.

All 16 Escape/resume/exit-confirm Playwright specs still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-06-28 12:32:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 290783b80b
commit fef870047b
7 changed files with 842 additions and 5 deletions
+185
View File
@@ -51,6 +51,14 @@ async function openPlayerWithMockSong(page) {
test.describe('Keyboard Shortcuts', () => {
test.beforeEach(async ({ page }) => {
// Suppress the first-run onboarding overlay (#v3-onboarding) — a modal that
// intercepts pointer/keyboard events — so the app behaves like a returning
// user, which is the state these tests assume.
await page.route('**/api/profile', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ json: { display_name: 'Test', player_hash: 'test', onboarded: true } });
} else { await route.continue(); }
});
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
});
@@ -778,6 +786,183 @@ test('should support condition callbacks', async ({ page }) => {
expect(result.clicked).toBe(1);
});
// ── Escape = universal "Back" carve-out ──────────────────────────────────
// Escape must escape a focused non-modal control exactly like Space does,
// so a focused transport/rail button can't swallow it ("Escape in song not
// consistent"). These mirror the #593 Space tests above. Each registers an
// Escape spy in the relevant scope (which replaces the built-in handler for
// that composite key) so the assertion doesn't depend on showScreen teardown.
test('Escape exits the song when a player rail button is focused', async ({ page }) => {
await openPlayerWithMockSong(page);
// The bug: a focused <button> is an "interactive control", so Escape was
// blocked before reaching the dispatcher and the song wouldn't exit until
// the user clicked empty canvas to blur the control.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const btn = document.createElement('button');
btn.id = '__test-rail-btn';
btn.textContent = 'Restart';
document.getElementById('player')!.appendChild(btn);
});
await page.locator('#__test-rail-btn').focus();
await expect(page.locator('#__test-rail-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Back-to-library fired despite the control button holding focus.
expect(backCount).toBe(1);
});
test('Escape in a player-screen text input does NOT exit the song', async ({ page }) => {
await openPlayerWithMockSong(page);
// The text-input exemption (_isTextInput) is checked before the Escape
// carve-out, so Escape in a field is the field's own concern (clear/blur),
// never a song exit.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const input = document.createElement('input');
input.type = 'text';
input.id = '__test-player-input';
document.getElementById('player')!.appendChild(input);
});
await page.locator('#__test-player-input').focus();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
expect(backCount).toBe(0);
});
test('Escape inside a modal over the player closes the modal, not back-to-library', async ({ page }) => {
await openPlayerWithMockSong(page);
// A true modal (role="dialog" aria-modal="true" / .feedBack-modal) layered
// over the player is a focus trap: Escape there must NOT eject past it to
// exit the song — the modal owns Escape. The carve-out's modal-overlay
// guard keeps the player-back shortcut from firing.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
const modal = document.createElement('div');
modal.id = '__test-modal';
modal.className = 'feedBack-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
const btn = document.createElement('button');
btn.id = '__test-modal-btn';
btn.textContent = 'Close';
modal.appendChild(btn);
document.body.appendChild(modal);
});
await page.locator('#__test-modal-btn').focus();
await expect(page.locator('#__test-modal-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Playback is NOT exited behind the modal.
expect(backCount).toBe(0);
});
test('Escape does NOT exit the song while the Section Practice popover is open', async ({ page }) => {
await openPlayerWithMockSong(page);
// The Section Practice popover claims Escape earlier in
// _shortcutDispatchBlocked (line ~447, before the Escape carve-out), so an
// open popover suppresses the player-scope back-to-library Escape — the
// popover's own handler owns closing it. This locks that ordering guard.
await page.evaluate(() => {
// @ts-ignore
window.__escBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Back to library (test spy)',
scope: 'player',
// @ts-ignore
handler: () => { window.__escBackCount++; },
});
let bar = document.getElementById('section-practice-bar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'section-practice-bar';
document.getElementById('player')!.appendChild(bar);
}
bar.classList.add('section-practice-bar--open');
});
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escBackCount);
// Player-back did NOT fire while the popover was open.
expect(backCount).toBe(0);
});
test('Escape goes back from settings when a control is focused (twin-bug)', async ({ page }) => {
// The same focus bug existed on the settings screen (the carve-out was
// player-only). The fix covers settings too: Escape returns to the
// previous screen even when a settings control holds focus.
await page.evaluate(() => {
// @ts-ignore
window.__escSettingsBackCount = 0;
// @ts-ignore
window.registerShortcut({
key: 'Escape',
description: 'Go back from settings (test spy)',
scope: 'settings',
// @ts-ignore
handler: () => { window.__escSettingsBackCount++; },
});
// @ts-ignore
window.showScreen('settings');
const btn = document.createElement('button');
btn.id = '__test-settings-btn';
btn.textContent = 'Some setting';
document.getElementById('settings')!.appendChild(btn);
});
await page.waitForSelector('#settings.active', { timeout: 5000 });
await page.locator('#__test-settings-btn').focus();
await expect(page.locator('#__test-settings-btn')).toBeFocused();
await page.keyboard.press('Escape');
const backCount = await page.evaluate(() => (window as any).__escSettingsBackCount);
expect(backCount).toBe(1);
});
test('should warn on invalid scope', async ({ page }) => {
const messages: string[] = [];
page.on('console', msg => {