mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
fix(v3): reject accidental text-selection of UI chrome (user-select policy) (#637)
* fix(v3): reject accidental text-selection of UI chrome (user-select policy)
Dragging/double-clicking across the v3 UI marquee-highlighted buttons, labels,
the sidebar, transport, and the note-highway HUD — looks broken (reported Mac +
Windows). Default the v3 shell to user-select:none on html, then opt CONTENT
back in. Decided by a 4-lens panel (UX / a11y / dev-ops / plugin-ecosystem);
their guardrails are baked in:
- Form fields ALWAYS re-enabled (input/textarea/select/[contenteditable]) so the
caret + IME composition never break. No `* { user-select:none }` (WebKit input
bug 82692).
- Plugin screens (.screen[id^="plugin-"]) stay selectable BY INHERITANCE (no `*`,
so a plugin's own non-select chrome still wins) — a plugin's copyable text
(lyrics, chords, results), including community/out-of-tree plugins that never
adopt the class, isn't silently locked.
- Core read-only content opts back in by CONTAINER via a hand-authored
`.fb-selectable` (not a Tailwind utility — so runtime-installed plugins get it
too): the whole Settings panel (paths, device names, version, diagnostics,
About) and the now-playing song metadata. Answers the open "keep settings
copyable?" question: yes, at the container.
Cosmetic only — never used to lock copy-worthy text (errors/IDs/paths/versions/
metadata stay selectable; WCAG 2.2 allows copy-paste as a mechanism). v3-only
(v2 unchanged; v3.css loads only on /v3); plain CSS, no Tailwind rebuild; no
desktop/Electron changes (standard OS-framed window). `.fb-selectable` is
documented in CLAUDE.md for plugin authors.
Tests: tests/js/v3_user_select_policy.test.js (html default, form-field
re-enable, plugin-screen carve without `*`, .fb-selectable, container opt-ins,
and the no-`*`-rule guardrail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF
* fix(v3): address review of the user-select policy (#637)
Review (manual + Codex) of the v3 text-selection policy:
- P1 (real bug): the now-playing HUD metadata opted into `.fb-selectable` but
its `#player-hud` parent is `pointer-events: none`, so the mouse could never
reach the text to select it — the opt-in was inert. Add `pointer-events-auto`
to the metadata block (verified in-browser: user-select:text + pointer-
events:auto, while the HUD parent stays pointer-events:none).
- Coverage: the PR's a11y guardrail promised copyable text stays selectable
"incl. in modals/toasts", but only Settings + the HUD were opted in. Blanket-
opt the focused copyable surfaces back in by selector — `.feedBack-modal`,
`[role="dialog"]`, `#fb-notify-stack`, `#v3-fb-toast`, `#scan-banner` — so
errors / IDs / paths / file names in dialogs, toasts, and the scan banner stay
copyable. These are focused panels, not dense card lists, so re-enabling
selection there can't recreate the across-cards marquee mess.
(Deliberately NOT opting in the library grid / dashboard / profile card lists:
making dense card text selectable would reintroduce exactly that marquee mess
on a drag — copy song metadata from the now-playing HUD / Settings instead.)
- Test (P3): assert the selectable rule's selectors order-independently, cover
the new modal/toast/banner surfaces, and check the HUD block carries BOTH
fb-selectable and pointer-events-auto (class-order independent).
Verified in a real browser (chromium): html=none, sidebar chrome=none, input=
text, Settings=text, HUD meta=text+pointer-events:auto, dialog/modal=text.
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
a791a0d8fe
commit
8fbbc761fc
@@ -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).
|
- **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
|
### 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.
|
- **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.)
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -552,6 +552,7 @@ a local pointer + code map.
|
|||||||
- **Storage** — `localStorage` for all user preferences
|
- **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.
|
- **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
|
- **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-<id>` 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.
|
- **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
|
## Backend Conventions
|
||||||
|
|||||||
+11
-2
@@ -343,7 +343,10 @@
|
|||||||
|
|
||||||
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
<!-- ══ SETTINGS ═══════════════════════════════════════════════════════ -->
|
||||||
<div id="settings" class="screen">
|
<div id="settings" class="screen">
|
||||||
<div class="fb-settings">
|
<!-- fb-selectable: Settings is read-only content (paths, device names,
|
||||||
|
version, diagnostics, About) the user copies — opt the whole panel
|
||||||
|
back in under the v3 non-select default. See static/v3/v3.css. -->
|
||||||
|
<div class="fb-settings fb-selectable">
|
||||||
<button onclick="showScreen('home')" class="fb-settings-back">
|
<button onclick="showScreen('home')" class="fb-settings-back">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg> Home
|
||||||
</button>
|
</button>
|
||||||
@@ -825,7 +828,13 @@
|
|||||||
|
|
||||||
<!-- Top HUD — persistent (song info, time, Up Next) -->
|
<!-- Top HUD — persistent (song info, time, Up Next) -->
|
||||||
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
|
<div id="player-hud" class="absolute top-0 left-0 right-0 flex justify-between items-start px-5 py-4 pointer-events-none z-20">
|
||||||
<div class="text-sm leading-tight">
|
<!-- fb-selectable: now-playing song metadata (title / artist /
|
||||||
|
arrangement / tuning) is copy-worthy content, not chrome.
|
||||||
|
pointer-events-auto: the #player-hud parent is pointer-events-none
|
||||||
|
(so the HUD doesn't eat clicks meant for the highway), which would
|
||||||
|
also block the mouse from reaching this text to select it — opt
|
||||||
|
just this block back into hit-testing. -->
|
||||||
|
<div class="text-sm leading-tight fb-selectable pointer-events-auto">
|
||||||
<div><span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span></div>
|
<div><span id="hud-artist" class="text-gray-300"></span> — <span id="hud-title" class="text-white font-semibold"></span></div>
|
||||||
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
|
<div id="hud-arrangement" class="text-gray-500 text-xs mt-0.5"></div>
|
||||||
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
|
<div id="hud-tuning" class="text-gray-500 text-xs mt-0.5"></div>
|
||||||
|
|||||||
@@ -4,6 +4,62 @@
|
|||||||
* `fb` palette in tailwind.config.js.
|
* `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. */
|
/* The v3 tuner card replaces the tuner plugin's floating launcher — hide it. */
|
||||||
#tuner-toggle-btn { display: none !important; }
|
#tuner-toggle-btn { display: none !important; }
|
||||||
|
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user