fix(console): the hide button was invisible AND clickable — a corner nobody could see dropped games
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 59s
ci / web (pull_request) Successful in 1m0s
ci / rust-arm64 (pull_request) Successful in 1m29s
ci / rust (pull_request) Successful in 4m39s

Hiding a library title shipped in 0.26.0 and was, in practice, unusable: the operator
reported there was simply no button, then found it by CLICKING the empty top-right corner
of a poster.

Both halves of that are the same defect. The card's control row was `opacity-0` until
`group-hover`, and `opacity-0` paints nothing while still HIT-TESTING. So the corner of
every tile in the grid was a live hide button with no visual presence at all: a stray
click there removed that title from every play surface — the client grid, the native
clients, the GameStream app list — with nothing on screen having suggested a control was
under the cursor. What read as "the button finally appeared" was the hide taking effect,
since `hidden` is the one state that drops the `opacity-0`. The feature announced itself
by firing.

And the reveal rested on hover ALONE. `:hover` never fires on a touch screen, so on a
tablet the hide control was unreachable by construction and discoverable only by the blind
click above. The original commit spotted this hazard for UN-hide — it kept those controls
always-visible so nobody could be stranded in the hidden state — but left the hide side
hover-gated, which is the same trap one step earlier.

So opacity and `pointer-events` now move together, always: whatever cannot be seen cannot
be clicked. `pointer-coarse:` shows the row outright wherever the device has no hover to
give, rather than making touch a second-class path. Keyboard reach is unchanged —
`pointer-events: none` does not block focus, so tabbing in still trips `focus-within`,
which now restores interactivity along with opacity.

The eye icon also gains a `title`. On a scanned entry it is the ONLY control on the card,
with no edit/delete beside it to read as a toolbar, and an unlabelled eye-with-slash is
not a promise that a game is about to leave the library.

Verified in the built CSS rather than by eye, because a variant that does not compile
fails exactly like the bug being fixed: `@media(pointer:coarse)` emits both
`pointer-coarse:opacity-100` and `pointer-coarse:pointer-events-auto`, and it lands at the
END of the sheet — media queries add no specificity, so this tie against the base
`.pointer-events-none` / `.opacity-0` is won on source order, not by accident. The
`group-hover:` and `focus-within:` forms compile to `:is(:where(.group):hover *)` and
`:focus-within`, carrying a pseudo-class each, so they win on specificity outright.

Console: tsc clean, production build clean, biome clean on the touched file, i18n 633
messages across en+de.
This commit is contained in:
2026-08-10 19:55:13 +02:00
parent e283f17ab4
commit e93947969f
+19 -3
View File
@@ -122,10 +122,22 @@ export const GameCard: FC<GameCardProps> = ({
{/* A hidden card keeps its controls VISIBLE rather than hover-revealed. Hover-to-reveal
is fine for an ordinary tile, but the un-hide button is the only way out of the
hidden state — requiring a hover to discover it would strand anyone on a touch
screen, which is exactly where the console's pointer work landed. */}
screen, which is exactly where the console's pointer work landed.
Two rules that used to be one, because `opacity-0` alone got BOTH of them wrong:
1. Invisible must also mean UNCLICKABLE. An `opacity-0` element paints nothing and
still hit-tests, so the top-right corner of every poster was a live hide button
nobody could see — a stray click there dropped that title from every play
surface with no visible cause. Opacity and `pointer-events` move together now.
2. The reveal cannot rest on hover ALONE. `:hover` never fires on a touch screen,
so the control was unreachable there by construction and discoverable only by
blind-clicking the corner. `pointer-coarse` shows it outright wherever the
device has no hover to give. Keyboard reach is unaffected: `pointer-events:
none` does not block focus, so tabbing in still trips `focus-within`. */}
<div
className={`absolute right-2 top-2 flex gap-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100${
hidden ? "" : " opacity-0"
className={`absolute right-2 top-2 flex gap-1 transition-opacity ${
hidden
? "opacity-100"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto pointer-coarse:opacity-100 pointer-coarse:pointer-events-auto"
}`}
>
<Button
@@ -135,6 +147,10 @@ export const GameCard: FC<GameCardProps> = ({
aria-label={
hidden ? m.library_unhide_action() : m.library_hide_action()
}
// A bare eye-with-slash is the only control on a SCANNED entry's card, with no
// edit/delete beside it to imply a toolbar. The native tooltip is what tells a
// pointer user what the icon does before they click and a game vanishes.
title={hidden ? m.library_unhide_action() : m.library_hide_action()}
aria-pressed={hidden}
disabled={hiding}
onClick={onToggleHidden}