diff --git a/CHANGELOG.md b/CHANGELOG.md index e31c455..01a63f5 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 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.) - **v3 Songs grid now refreshes after a Settings rescan / DLC-folder change — no app restart needed.** On a fresh install, pointing at a DLC folder in Settings and running a scan left the Songs section empty until a restart (the scan *did* populate the library — `_background_scan` re-reads `config.json` fresh — but the v3 grid never reloaded). The Settings **Rescan / Full Rescan** handlers only refreshed the classic (v2) library via `loadLibrary()`; the v3 grid (`static/v3/songs.js`) had no listener for a scan it didn't start itself (only its own upload path self-refreshed via `watchUploadScan`), so its cached, pre-DLC (empty) DOM/snapshot survived a sidebar return until a full reload. The rescan handlers now emit a **`library:changed`** event (`static/app.js`); the v3 grid listens and **reloads if it's the active screen, else marks itself dirty** so the next entry does a full re-fetch instead of restoring the stale snapshot (a `_libraryDirty` short-circuit ahead of every cached-DOM fast-path in `onV3SongsScreenEnter`). Tests: `tests/js/v3_library_refresh.test.js` (the emit + the reload/dirty wiring). diff --git a/CLAUDE.md b/CLAUDE.md index 07744a6..74e6f66 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -552,6 +552,7 @@ a local pointer + code map. - **Storage** — `localStorage` for all user preferences - **Styling** — Tailwind CSS utility classes, dark theme (`bg-dark-600`, `text-gray-300`, accent `#4080e0`, gold `#e8c040`). Tailwind is served as a **prebuilt** stylesheet (`static/tailwind.min.css`, regenerated by `bash scripts/build-tailwind.sh`), **never** the runtime Play CDN — the CDN's on-the-fly JIT rescanned the DOM on the main thread and dropped ~26% of frames with the 3D highway (feedBack-desktop#110). The committed CSS only contains classes the build scanner saw, so CI (`tailwind-fresh`) rebuilds and diffs it; run the build script and commit when you add new classes. A plugin that uses classes not guaranteed in core (notably arbitrary values like `w-[37px]`) MUST ship its own compiled stylesheet via the `styles` manifest key, built with `corePlugins.preflight = false` (utilities only — core ships the one base reset). Plugins MUST NOT load the Tailwind Play CDN or any runtime CSS JIT. See constitution Principle II. - **Naming** — camelCase for JS functions, kebab-case for CSS classes, snake_case for plugin IDs +- **Text selection (v3)** — the v3 UI defaults to `user-select: none` on `html` (in `static/v3/v3.css`) so accidental drag/double-click selection of chrome never looks broken. Form fields are always re-enabled, and a **plugin's mounted screen subtree (`.screen[id^="plugin-"]`) stays selectable by default**, so a plugin's copy-worthy text (lyrics, chord names, results, diagnostics) is unaffected — *unless your plugin renders copyable content OUTSIDE its `plugin-` screen* (e.g. injected into the player chrome / a HUD overlay), which inherits the non-select default. Opt such content back in with the core-served **`.fb-selectable`** class (it sets `user-select: text` on the element + descendants; works for runtime-installed plugins since it's hand-authored in core CSS, not a scanned Tailwind utility). Never use a `* { user-select: none }` rule (breaks input carets/IME), and never use `user-select: none` to "lock" text — keep errors, IDs, paths, versions, and metadata selectable. (v2 is unchanged.) - **Player layout** — `#player` is `display:flex; flex-direction:column; position:fixed; inset:0`. `#highway` is `flex:1`. `#player-controls` sits at the bottom. Hiding the highway collapses the layout — use `margin-top: auto` on controls if you need to hide it. ## Backend Conventions diff --git a/static/v3/index.html b/static/v3/index.html index 992f3b3..d25f14f 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -343,7 +343,10 @@
-
+ +
@@ -825,7 +828,13 @@
-
+ +
diff --git a/static/v3/v3.css b/static/v3/v3.css index 3045fdd..e9caf26 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -4,6 +4,62 @@ * `fb` palette in tailwind.config.js. */ +/* ── Text-selection policy (v3) ────────────────────────────────────────────── + Accidental drag/double-click selection of app chrome (sidebar, transport, the + note highway/HUD, buttons, labels) makes the UI look broken and is never + useful — so default the interface to non-selectable, then opt *content* back + in. v3-only: this sheet loads only on /v3 (v2 is unchanged). The panel's + guardrails are baked in: + - NEVER a `* { user-select:none }` rule — it breaks input carets / IME + composition on WebKit (bug 82692); we scope to `html` and re-enable below. + - This is cosmetic only; it protects nothing (DevTools defeats it) and must + never be used to "lock" copy-worthy text away (a11y: keep errors, IDs, + paths, versions, metadata, lyrics selectable — incl. in modals/toasts). */ +html { -webkit-user-select: none; user-select: none; } + +/* Form fields are ALWAYS selectable/editable — protects the caret + IME + (including CJK / dead-key composition). The default must never swallow typing. + `.fb-selectable *` forces descendants so a child element's own non-select + can't strand copy-worthy text inside a content island. */ +input, textarea, select, +[contenteditable]:not([contenteditable="false"]), +[contenteditable]:not([contenteditable="false"]) * { + -webkit-user-select: text; user-select: text; +} + +/* Plugin screens are content surfaces (editor, tabview, lyrics, theory, chord + text, …). Re-enable their mounted subtree by INHERITANCE (no `*`) so the host + policy can't silently make a plugin's copyable text un-selectable — including + community / out-of-tree plugins that never adopt `.fb-selectable`. A plugin + that wants its own chrome non-selectable still wins via its own element rule + (which this inherited value doesn't override). */ +.screen[id^="plugin-"] { -webkit-user-select: text; user-select: text; } + +/* Core read-only content opts back in by CONTAINER (lower-drift than tagging + each value — a new setting added later inherits "selectable" for free): + the Settings panel (values, paths, device names, version, diagnostics, + About) and the now-playing song metadata (both tagged `.fb-selectable`). + Plugins re-enable their own copyable regions with this same class + (documented in CLAUDE.md). + + The focused, transient surfaces below ALWAYS carry copy-worthy text (errors, + IDs, file paths, device/version strings) per the a11y guardrail, so they're + blanket-opted-in by selector rather than hand-tagged — they're single focused + panels, not dense card lists, so re-enabling selection there can't recreate + the across-cards marquee mess the policy prevents: + - modals / dialogs: `.feedBack-modal`, `[role="dialog"]` (confirm, edit-meta, + retune result/error, calibration, filter drawer); + - toasts: `#fb-notify-stack`, `#v3-fb-toast`; + - the library scan banner (`#scan-banner` — shows the current file path). + (Dense card lists — the library grid, dashboard, profile — are intentionally + left non-selectable; copy their text from the now-playing HUD / Settings.) */ +.fb-selectable, .fb-selectable *, +.feedBack-modal, .feedBack-modal *, +[role="dialog"], [role="dialog"] *, +#fb-notify-stack, #fb-notify-stack *, +#v3-fb-toast, #v3-fb-toast *, +#scan-banner, #scan-banner * { -webkit-user-select: text; user-select: text; } + /* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */ #tuner-toggle-btn { display: none !important; } diff --git a/tests/js/v3_user_select_policy.test.js b/tests/js/v3_user_select_policy.test.js new file mode 100644 index 0000000..46d54f7 --- /dev/null +++ b/tests/js/v3_user_select_policy.test.js @@ -0,0 +1,80 @@ +// Guards the v3 text-selection policy (static/v3/v3.css + static/v3/index.html): +// the UI defaults to non-selectable so accidental chrome selection can't look +// broken, while form fields, plugin screens, and core content opt back in. A +// future global reset clobbering the rule — or the content containers losing +// their .fb-selectable opt-in — 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 root = path.join(__dirname, '..', '..'); +// Strip block comments so the policy's own explanatory prose (which quotes the +// `* { user-select:none }` anti-pattern as a warning) can't trip the assertions. +const css = fs.readFileSync(path.join(root, 'static', 'v3', 'v3.css'), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ''); +const html = fs.readFileSync(path.join(root, 'static', 'v3', 'index.html'), 'utf8'); + +test('v3 defaults to non-selectable on html (not a universal `*` rule)', () => { + assert.match(css, /html\s*\{[^}]*user-select:\s*none/, + 'html must default user-select: none'); + // The `* { user-select: none }` anti-pattern breaks input carets / IME — must not exist. + assert.doesNotMatch(css, /\*\s*\{[^}]*user-select:\s*none/, + 'must NOT use a universal `*` user-select:none rule'); +}); + +test('form fields are always re-enabled (caret / IME safe)', () => { + assert.match( + css, + /input,\s*textarea,\s*select[\s\S]*?contenteditable[\s\S]*?user-select:\s*text/, + 'input/textarea/select/[contenteditable] must be re-enabled to user-select: text', + ); +}); + +test('plugin screen subtree stays selectable by inheritance (no `*`, respects plugin opt-outs)', () => { + assert.match( + css, + /\.screen\[id\^="plugin-"\]\s*\{[^}]*user-select:\s*text/, + 'plugin screens must be re-enabled so plugin content is not silently un-copyable', + ); + assert.doesNotMatch( + css, + /\.screen\[id\^="plugin-"\]\s*\*/, + 'the plugin carve must NOT use `*` (would override a plugin\'s own non-select chrome)', + ); +}); + +// The rule that re-enables selection on copyable content. Find the single +// declaration block whose body sets `user-select: text`, then assert each +// required selector is one of its selectors — order/format independent. +const selectableRule = (css.match(/([^{}]*)\{[^}]*user-select:\s*text[^}]*\}/g) || []) + .join('\n'); + +test('core content opts back in via .fb-selectable (element + descendants)', () => { + assert.match(selectableRule, /\.fb-selectable\b/, '.fb-selectable must set user-select: text'); + assert.match(selectableRule, /\.fb-selectable\s*\*/, '...and its descendants (.fb-selectable *)'); +}); + +test('focused copyable surfaces (modals/toasts/scan banner) opt back in', () => { + // The PR\'s a11y guardrail keeps copyable text selectable "incl. in + // modals/toasts" — these carry errors / IDs / paths the user copies. + assert.match(selectableRule, /\.feedBack-modal\b/, 'modals (.feedBack-modal) must be selectable'); + assert.match(selectableRule, /\[role="dialog"\]/, 'dialogs ([role="dialog"]) must be selectable'); + assert.match(selectableRule, /#fb-notify-stack\b/, 'toasts (#fb-notify-stack) must be selectable'); + assert.match(selectableRule, /#scan-banner\b/, 'the scan banner (#scan-banner) must be selectable'); +}); + +// Match a class="" attribute that contains ALL given tokens in any order. +const hasClasses = (...tokens) => new RegExp( + 'class="' + tokens.map((t) => '(?=[^"]*\\b' + t + '\\b)').join('') + '[^"]*"'); + +test('the Settings panel and now-playing metadata carry .fb-selectable', () => { + assert.match(html, hasClasses('fb-settings', 'fb-selectable'), + 'the Settings panel must opt back in (paths / version / diagnostics / About)'); + assert.match(html, hasClasses('fb-selectable', 'pointer-events-auto'), + 'the now-playing metadata must opt back in AND re-enable pointer-events ' + + '(its #player-hud parent is pointer-events-none, which would block mouse selection)'); +});