diff --git a/CHANGELOG.md b/CHANGELOG.md index 01a63f5..ba961e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers). ### Fixed +- **v3 player: opening another rail popover now closes the Section Practice popover (no more two stacked popovers).** Opening the **Practice** pill's popover and then clicking a different player-rail icon (e.g. **Plugins**) left the Practice popover open underneath the new one — looked broken (reported on macOS, 0.3.0 / 2026-06-28). The rail icons call `e.stopPropagation()` in their click handler (`static/v3/player-chrome.js`), which killed bubbling before it reached the Practice popover's outside-click dismiss bound on `document`. The dismiss (`_installSectionPracticeDismiss` in `static/app.js`) now binds in the **capture phase**, which runs before the target's handler so a descendant's `stopPropagation()` can't swallow it — mirroring how the audio-mixer popover already dismisses. Esc handling stays bubble-phase (the player's Escape-to-exit ordering is unchanged). v2 shares `app.js` and is only hardened (no rail `stopPropagation` there). Tests: `tests/js/section_practice_dismiss.test.js`. - **v3 UI no longer lets you accidentally text-select the chrome.** Dragging or double-clicking across the interface used to marquee-highlight buttons, labels, the sidebar, the transport, and the note-highway HUD — which looks broken (reported on Mac + Windows). The v3 shell now defaults to `user-select: none` on `html` (`static/v3/v3.css`), then opts *content* back in — so chrome is non-selectable but the text you actually copy still works. Decided by a 4-lens panel (UX / accessibility / dev-ops / plugin-ecosystem); the guardrails are deliberate: **form fields are always re-enabled** (never break the caret / IME — no `* { user-select:none }`, which trips a WebKit input bug); **plugin screens (`.screen[id^="plugin-"]`) stay selectable by default** so a plugin's copyable text (lyrics, chord names, results) — including community plugins that don't know about this — isn't silently locked; and **core read-only content opts back in by container** via a new hand-authored **`.fb-selectable`** class — applied to the whole **Settings** panel (paths, device names, version, diagnostics, About — answering "is settings still copyable?": yes), the **now-playing song metadata** (with `pointer-events` re-enabled so the HUD text is actually reachable), and the focused **modals / dialogs / toasts / scan banner** that carry copyable errors, IDs, paths, and file names. It's cosmetic only (it protects nothing) and never used to lock copy-worthy text — errors, IDs, paths, versions, and metadata stay selectable per WCAG 2.2 (copy-paste as a permitted mechanism). Dense card lists (library grid, dashboard, profile) stay non-selectable by design — making them selectable would reintroduce the marquee-mess across cards. **v3-only** (v2 unchanged); plain CSS, no Tailwind rebuild; no desktop changes (standard OS-framed window). Plugin authors: `.fb-selectable` is documented in `CLAUDE.md` for re-enabling copyable content rendered outside a plugin screen. Tests: `tests/js/v3_user_select_policy.test.js`. - **Input-setup wizard no longer collapses an audio device's driver-type variants into one entry.** On Windows the desktop engine enumerates the same interface once per host API (ASIO / Windows Audio / DirectSound), and the wizard's audio picker (`plugins/input_setup/screen.js`) de-duped the source list by display **label** — so the variants (which share a name) collapsed to a single choice, silently keeping whichever sorted first (often *not* the low-latency ASIO one the player wants). The audio-input capability already collapses true duplicates by `logicalSourceKey` (`_visibleInputSources` in `static/capabilities/audio-session.js`), and the variants each have a **distinct** key, so the wizard's extra label-collapse was redundant for real dupes and destructive for these — it also could drop the variant that was actually `selected`. Removed it; the picker now lists every selectable input. Pairs with feedBack-desktop's change to label each source with its driver type (e.g. "Focusrite (ASIO)") so the now-distinct entries are legible. - **3D Highway FPS counter no longer hides behind the v3 "Up Next" pill.** The on-highway FPS readout (Settings → Graphics → 3D Highway → Show FPS counter) is pinned to the top-right of the highway overlay — the same corner the v3 player chrome stacks its persistent **Up Next** pill and live-performance HUD into, on a higher layer that paints over the canvas. So the readout sat *behind* that chrome and couldn't be read — precisely when a tester had turned it on to judge performance (it also made the separate "Up Next won't turn off" complaint worse, since the default-on pill covered the counter regardless). The counter now stays top-right but drops just **below** whichever of that chrome is showing: `highway_3d`'s `screen.js` measures the lowest visible top-right v3 HUD element (`#v3-upnext` / `#v3-live-performance-hud` / `#hud-time`) and floors the FPS box's Y beneath it. Element refs are resolved once and cached (no per-frame `querySelector`, per the plugin perf rules) and only consulted while the counter is actually drawn; gated on `window.feedBack.uiVersion === 'v3'` so the classic (v2) UI is byte-for-byte unaffected. `plugins/highway_3d/plugin.json` version → `3.30.1` (cache-buster). (For reading raw perf numbers unobstructed, the core perf HUD — `localStorage.highwayPerfHud='1'` — still renders above all chrome and additionally shows the adaptive render-scale.) diff --git a/static/app.js b/static/app.js index 60031cd..6c00304 100644 --- a/static/app.js +++ b/static/app.js @@ -8714,12 +8714,24 @@ function _installSectionPracticeDismiss() { // inside #section-practice-control so it never self-closes. Listeners added // mid-dispatch don't fire for the opening click, so there's no immediate // close race. + // + // The click listener uses the CAPTURE phase: the v3 player rail's icon + // buttons call e.stopPropagation() in their click handler (player-chrome.js + // wireRail), which kills bubbling before it reaches document. A bubble-phase + // outside-click dismiss would therefore never fire when the user clicks a + // rail icon (Plugins, Audio, …) to open another popover, leaving this + // popover stranded open on top of it. Capture runs before the target's + // handler, so the stopPropagation can't swallow it. This mirrors the audio + // mixer popover (audio-mixer.js), which dismisses outside-clicks the same + // way. (Esc stays bubble-phase — no rail handler stops keydown propagation, + // so it already reaches us, and capturing it would reorder it ahead of the + // player's Escape-to-exit handling.) document.addEventListener('click', (e) => { if (!_sectionPracticePopoverOpen()) return; const ctrl = document.getElementById('section-practice-control'); if (ctrl && ctrl.contains(e.target)) return; _closeSectionPracticePopover(); - }); + }, true); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover(); }); diff --git a/tests/js/section_practice_dismiss.test.js b/tests/js/section_practice_dismiss.test.js new file mode 100644 index 0000000..8a0912c --- /dev/null +++ b/tests/js/section_practice_dismiss.test.js @@ -0,0 +1,43 @@ +// Guards the Section Practice popover's outside-click dismiss in static/app.js +// (_installSectionPracticeDismiss). The v3 player-rail icon buttons call +// e.stopPropagation() in their click handler (static/v3/player-chrome.js +// wireRail), so a BUBBLE-phase document dismiss never fires when the user clicks +// a different rail icon (Plugins, Audio, …) — leaving the Practice popover +// stranded open under the newly-opened one (feedBack#638). The dismiss must bind +// in the CAPTURE phase (runs before the target's stopPropagation can swallow it). +// Esc must stay bubble-phase so it doesn't reorder ahead of the player's +// Escape-to-exit handling. A revert to bubble-phase should fail here. +// +// Source-level only — same strategy as the other tests/js/ files. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8'); +const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/); +assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js'); +const body = m[0]; + +test('the outside-click dismiss binds in the CAPTURE phase', () => { + assert.match( + body, + /addEventListener\(\s*['"]click['"][\s\S]*?,\s*true\s*\)/, + 'the click dismiss must pass the capture flag (`, true`) so a rail icon\'s ' + + 'stopPropagation() cannot swallow it', + ); +}); + +test('only the click listener is capture (Escape keydown stays bubble-phase)', () => { + // Exactly one capture binding in the installer — the click. The keydown + // (Escape) listener must NOT be capture. + const captureBinds = body.match(/,\s*true\s*\)/g) || []; + assert.equal(captureBinds.length, 1, 'expected exactly one capture-phase binding (the click)'); +}); + +test('the dismiss ignores clicks inside the control (no self-close)', () => { + assert.match(body, /section-practice-control/, 'must scope to #section-practice-control'); + assert.match(body, /ctrl\s*&&\s*ctrl\.contains\(e\.target\)\)\s*return/, + 'a click inside the control (incl. the pill) must not dismiss the popover'); +});