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:
ChrisBeWithYou
2026-06-29 14:44:56 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent a791a0d8fe
commit 8fbbc761fc
5 changed files with 149 additions and 2 deletions
+56
View File
@@ -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; }