diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5aa4a335..16ff7429 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -295,7 +295,7 @@ function Thumb({ )} {/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only: - reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and + reveal on hover AND on keyboard focus (else a Tab-focused button is invisible), and `reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */} {onOpen && ( diff --git a/frontend/src/components/mediaPicture.contract.test.ts b/frontend/src/components/mediaPicture.contract.test.ts index 27f52094..3b587c69 100644 --- a/frontend/src/components/mediaPicture.contract.test.ts +++ b/frontend/src/components/mediaPicture.contract.test.ts @@ -1,6 +1,8 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import videoCard from "./VideoCard.tsx?raw"; +import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; import { MEDIA_BAR, @@ -38,3 +40,28 @@ describe("a media picture", () => { expect(layer).not.toMatch(/pic:pb-\d/); }); }); + +const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8"); + +// Back from a channel page hands the card its focus again, after a mouse trip: with `focus-within` +// its hover row stayed up under no pointer until a reload (owner). The chrome answers the pointer and +// KEYBOARD focus only (`:focus-visible` on the card or inside it); jsdom cannot evaluate either, so +// the classes are the contract, and the global `.reveal` rule says the same. +describe("a card's hover chrome", () => { + it.each([ + ["the hover row", HOVER_REVEAL], + ["what yields to it", HOVER_YIELD], + ["the Play layer", MEDIA_HOVER_LAYER], + ["the Continue + Restart layer", MEDIA_RESUME_LAYER], + ])("shows on keyboard focus, never on a focus handed back: %s", (_, cls) => { + const parts = cls.split(" "); + expect(parts.some((c) => c.startsWith("group-focus-within:"))).toBe(false); + expect(parts.some((c) => c.startsWith("group-focus-visible:"))).toBe(true); + expect(parts.some((c) => c.startsWith("group-has-focus-visible:"))).toBe(true); + }); + + it("is the same in the global .reveal rule", () => { + expect(indexCss).not.toMatch(/\.group:focus-within \.reveal/); + expect(indexCss).toMatch(/\.group:has\(:focus-visible\) \.reveal/); + }); +}); diff --git a/frontend/src/components/ui/mediaCardStyles.ts b/frontend/src/components/ui/mediaCardStyles.ts index 45a74001..302fe842 100644 --- a/frontend/src/components/ui/mediaCardStyles.ts +++ b/frontend/src/components/ui/mediaCardStyles.ts @@ -32,7 +32,7 @@ export const MEDIA_PICTURE_CONTAINER = "@container/pic has-[.media-bar]:[--bar-r * (measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. * Only where a row is there to clear (`--bar-room`, MEDIA_PICTURE_CONTAINER). */ export const MEDIA_HOVER_LAYER = - "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-(--bar-room)"; + "reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 group-has-focus-visible:opacity-100 @max-[14rem]/pic:pb-(--bar-room)"; /** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the * picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */ diff --git a/frontend/src/components/ui/reveal.ts b/frontend/src/components/ui/reveal.ts index e960f114..7ae7c1c0 100644 --- a/frontend/src/components/ui/reveal.ts +++ b/frontend/src/components/ui/reveal.ts @@ -1,8 +1,12 @@ -// A card's hover chrome: shown when the card is hovered or holds keyboard focus on a mouse, +// A card's hover chrome: shown when the card is hovered or holds KEYBOARD focus on a mouse, // removed on touch (`.reveal-hover`, index.css), where the card offers the same actions through its // "…" beside the title. One string, so the video card and the Plex cards reveal the same way. +// Keyboard focus is `:focus-visible` on the card or inside it, not any focus: Back from a channel +// page hands the card its focus back, and with `:focus-within` the row stayed up under no pointer +// until a reload (owner). export const HOVER_REVEAL = - "reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"; + "reveal-hover opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 group-has-focus-visible:opacity-100 transition-opacity"; /** What sits under that chrome at rest (a duration, a watched pill) steps aside while it shows. */ -export const HOVER_YIELD = "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"; +export const HOVER_YIELD = + "transition-opacity group-hover:opacity-0 group-focus-visible:opacity-0 group-has-focus-visible:opacity-0"; diff --git a/frontend/src/index.css b/frontend/src/index.css index d6adc705..44b1b26a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -787,7 +787,8 @@ body { `opacity-0 group-hover:opacity-100`: fine on a mouse (the enclosing `.group` reveals it on hover), but on a touch device — which never hovers — it is invisible AND still tappable. So the reveal is gated on `@media (hover: hover)`: only a hover-capable pointer hides it until the group is hovered - or focused (keyboard too, via `:focus-within`); a touch device keeps the default opacity:1 and the + or keyboard-focused (`:focus-visible` on it or inside it — not any focus: a card handed its focus + back after a mouse trip kept its row up); a touch device keeps the default opacity:1 and the control is always shown. Requires a `.group` ancestor (Tailwind's group marker). */ @media (hover: hover) { .reveal { @@ -795,7 +796,8 @@ body { transition: opacity 150ms ease; } .group:hover .reveal, - .group:focus-within .reveal, + .group:focus-visible .reveal, + .group:has(:focus-visible) .reveal, .reveal:focus-visible { opacity: 1; } @@ -810,7 +812,7 @@ body { @media (hover: none) { /* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch), a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an - invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…` + invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-visible:…` (which still governs the hover + keyboard case). For a full-card/edge affordance whose action is already reachable on touch another way — a card's hover row (its "…" beside the title opens the same actions: `ui/reveal.ts`), VideoCard's play overlay (tapping the card plays/resumes)