fix(v3): keep Section Map's leftmost section clickable under the rail catcher (#617)

The section_map plugin pins a ~20px clickable bar (#section-map, z-index:5)
to the top of #player. The v3 left-rail hover-catcher (.v3-railzone::before)
is full-height at z-index:30 with pointer-events:auto, so its top-left
corner swallowed every click on the section map's first section — the
left-most section was never clickable on the v3 desktop (macOS/Windows) UI.

Drop the catcher below the 20px bar when the section map is present,
mirroring the existing #section-map ~ #player-hud special-case in
static/style.css. The rail still reveals from anywhere below the bar.

Adds a Playwright regression test (hit-test of the top-left corner) with a
negative control that re-raises the catcher to reproduce the bug.

Fixes #616

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-06-27 21:07:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent a57d0e3f85
commit b206633131
2 changed files with 100 additions and 0 deletions
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
// Regression coverage for the v3 Section Map "leftmost section unclickable" bug.
//
// The Section Map plugin pins a ~20px clickable bar (#section-map, z-index:5)
// to the very top of #player. The v3 chrome has a full-height invisible rail
// "catcher" (.v3-railzone::before, z-index:30, width:96px, pinned left/top:0)
// that reveals the hover rail. Because the catcher sat at top:0 and outranks
// the bar, its top-left corner swallowed every click on the section map's first
// section. Fix (static/v3/v3.css): `#section-map ~ #v3-railzone::before { top: 20px }`
// drops the catcher below the bar when the section map is present.
//
// We reproduce the plugin's bar exactly (first child of #player, the rendered
// position:relative / z-index:5 / 20px-tall state) and hit-test the top-left
// corner with elementFromPoint — that is precisely what a real click resolves
// against. A negative control re-raises the catcher to prove the test catches
// the bug.
// A fresh profile shows the blocking onboarding overlay; onboard via the API so
// it isn't (re)created over the player. Idempotent once onboarded.
test.beforeEach(async ({ request }) => {
await request.post('/api/profile', { data: { display_name: 'Section Map Tester' } });
await request.post('/api/progression/paths', { data: { add: ['guitar'] } });
await request.post('/api/progression/onboarding', { data: { action: 'skip' } });
});
async function openPlayerWithSectionMap(page) {
await page.goto('/');
await page.waitForSelector('.screen.active', { timeout: 10000 });
// The bug affects an already-onboarded user mid-song. The API skip above
// handles the common path; this persistent hide also covers a slow async
// profile render that could otherwise re-create the full-screen overlay and
// intercept the top-left hit-test (mirrors settings-tabbed.spec.ts).
await page.addStyleTag({ content: '#v3-onboarding{display:none!important;pointer-events:none!important}' });
await page.evaluate(() => {
// @ts-ignore — show the player screen (static #v3-railzone markup lives here).
window.showScreen('player');
const player = document.getElementById('player');
if (!player) throw new Error('#player missing');
// Reproduce the section_map plugin's rendered bar: first child of #player,
// 20px tall, full width, z-index:5, position:relative (its post-_smRender
// state), with a left-edge "first section" block at left:0.
const bar = document.createElement('div');
bar.id = 'section-map';
bar.style.cssText =
'position:relative;top:0;left:0;right:0;z-index:5;height:20px;background:rgba(8,8,16,0.7);cursor:pointer;';
const block = document.createElement('div');
block.id = 'sm-first-block';
block.style.cssText =
'position:absolute;left:0;width:30%;top:0;bottom:0;background:#3b82f6;';
bar.appendChild(block);
player.insertBefore(bar, player.firstChild);
});
await page.waitForSelector('#section-map', { state: 'attached', timeout: 5000 });
await page.waitForSelector('#v3-railzone', { state: 'attached', timeout: 5000 });
}
// What element does a click at the top-left strip land on? (x within the 96px
// catcher, y within the 20px bar.)
function hitTopLeft(page, x = 10, y = 8) {
return page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
return el ? { id: el.id, cls: el.className, tag: el.tagName } : null;
}, { x, y });
}
test('top-left of the section map receives clicks, not the rail catcher (fix present)', async ({ page }) => {
await openPlayerWithSectionMap(page);
const hit = await hitTopLeft(page);
// Click must resolve to the section map (the bar or its first-section block),
// never the rail hover-zone.
expect(hit).not.toBeNull();
expect(hit!.id).not.toBe('v3-railzone');
expect(['section-map', 'sm-first-block']).toContain(hit!.id);
});
test('negative control: re-raising the catcher to top:0 reproduces the bug', async ({ page }) => {
await openPlayerWithSectionMap(page);
// Undo the fix at runtime (highest-specificity inline-ish override) so the
// catcher again covers the bar's top-left — this is the pre-fix layout.
await page.evaluate(() => {
const style = document.createElement('style');
style.textContent = '#section-map ~ #v3-railzone::before { top: 0 !important; }';
document.head.appendChild(style);
});
const hit = await hitTopLeft(page);
// Without the fix, the rail catcher swallows the click.
expect(hit!.id).toBe('v3-railzone');
});