diff --git a/frontend/src/components/Messages.tsx b/frontend/src/components/Messages.tsx
index 0a0b629b..64559ff2 100644
--- a/frontend/src/components/Messages.tsx
+++ b/frontend/src/components/Messages.tsx
@@ -189,7 +189,7 @@ function ConversationList({
onClick={() => openChat(c.partner)}
title={t("messages.popOut")}
aria-label={t("messages.popOut")}
- className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted opacity-0 group-hover:opacity-100 hover:text-accent hover:bg-bg transition"
+ className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
>
diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx
index 041230a3..e2a00d3e 100644
--- a/frontend/src/components/Modal.tsx
+++ b/frontend/src/components/Modal.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, type ReactNode } from "react";
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
@@ -6,6 +6,14 @@ import { useBackToClose } from "../lib/history";
import { pushLayer, removeLayer, isTopmost } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
+// Tabbable elements inside the dialog, in DOM order. `:not([tabindex="-1"])` drops programmatically
+// focusable-only nodes; the visibility filter (offsetParent) drops display:none ones (e.g. a
+// collapsed disclosure) so the trap never lands focus on something the user can't see.
+const FOCUSABLE =
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
+const focusablesIn = (root: HTMLElement): HTMLElement[] =>
+ [...root.querySelectorAll(FOCUSABLE)].filter((el) => el.offsetParent !== null);
+
// Small centered modal shell: backdrop + ESC + scroll-lock close. Each open modal is a layer in the
// shared `layerStack` so ESC only closes the topmost one — e.g. an error dialog over the tag editor
// dismisses just the error and returns to the editor underneath, and a player below defers to it
@@ -27,6 +35,13 @@ export default function Modal({
// Browser/mouse Back closes the topmost modal instead of navigating away.
useBackToClose(onClose);
const fsRoot = useFullscreenElement();
+ const panelRef = useRef(null);
+ const titleId = useId();
+ // Capture the trigger to restore focus to, during THIS render — i.e. before commit runs the
+ // dialog's autoFocus (or our own initial-focus) and moves activeElement into the dialog. Reading
+ // it later (in the effect) would capture a now-inside element that unmounts on close, stranding
+ // focus on . useState initializer runs exactly once, on first render.
+ const [restoreTo] = useState(() => document.activeElement as HTMLElement | null);
// Backdrop-click close must only fire when the press STARTED on the backdrop too. Otherwise a
// drag that begins inside the dialog (e.g. selecting text in an input) and is released out on
@@ -39,20 +54,55 @@ export default function Modal({
useEffect(() => {
const id = pushLayer();
const onKey = (e: KeyboardEvent) => {
- if (e.key === "Escape" && isTopmost(id)) {
+ if (!isTopmost(id)) return;
+ if (e.key === "Escape") {
e.stopPropagation();
onCloseRef.current();
+ return;
+ }
+ // Focus trap: keep Tab / Shift+Tab cycling inside the dialog instead of reaching the inert
+ // page behind the backdrop. Only the topmost modal traps (a nested one defers to its child).
+ if (e.key === "Tab") {
+ const panel = panelRef.current;
+ if (!panel) return;
+ const items = focusablesIn(panel);
+ const first = items[0];
+ const last = items[items.length - 1];
+ if (!first || !last) {
+ e.preventDefault();
+ return;
+ }
+ const active = document.activeElement;
+ if (!panel.contains(active)) {
+ // Focus escaped the dialog (or sits on the panel itself) — pull it back to an edge.
+ e.preventDefault();
+ (e.shiftKey ? last : first).focus();
+ } else if (e.shiftKey && active === first) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && active === last) {
+ e.preventDefault();
+ first.focus();
+ }
}
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
+ // Initial focus: unless an autoFocus child already placed it inside (e.g. a rename input), move
+ // focus to the first control so the dialog opens focused, not stranded on the background.
+ const panel = panelRef.current;
+ if (panel && !panel.contains(document.activeElement)) {
+ (focusablesIn(panel)[0] ?? panel).focus();
+ }
return () => {
window.removeEventListener("keydown", onKey);
removeLayer(id);
document.body.style.overflow = prev;
+ // Restore focus to the trigger, if it's still in the document and focusable.
+ if (restoreTo && restoreTo.isConnected) restoreTo.focus();
};
- }, []);
+ }, [restoreTo]);
return createPortal(
-
+
-
{title}
+
+ {title}
+
)}
diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx
index b2a1f77b..24bf28a8 100644
--- a/frontend/src/components/VideoCard.tsx
+++ b/frontend/src/components/VideoCard.tsx
@@ -62,7 +62,7 @@ function Actions({
// rather than silently rendering a half-clipped control outside the card.
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index 1e2b7b84..a9a465c9 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -80,7 +80,7 @@ export function PlexPosterCard({
onToggleWatched(card);
}}
title={card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
- className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
+ className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
)}
- {/* Watched badge when not hovering (the toggle replaces it on hover). */}
+ {/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card
+ has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch,
+ where the toggle is always shown); a watched show keeps the badge as its sole marker. */}
{card.status === "watched" && (
-
+
{t("plex.watched")}
)}
@@ -203,7 +209,7 @@ export function SeasonCard({
title={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
- className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
+ className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
@@ -216,18 +222,26 @@ export function SeasonCard({
onAdd();
}}
title={t("plex.playlist.addSeason")}
- className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
+ className="absolute bottom-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
)}
{watched && (
-
+
{t("plex.watched")}
)}
{se.status === "in_progress" && (
-
+
{t("plex.inProgress")}
)}
@@ -406,7 +420,7 @@ export function EpisodeCard({
onToggleWatched();
}}
title={ep.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched")}
- className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
+ className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
diff --git a/frontend/src/index.css b/frontend/src/index.css
index ce4b33c9..3e9d8e4b 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -536,6 +536,38 @@ body {
0 14px 30px -14px rgba(0, 0, 0, 0.5);
}
+/* UX-7 — touch-visible reveal for hover-only card controls. A control tagged `.reveal` used to be
+ `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
+ control is always shown. `.reveal-idle` is the counterpart badge a control replaces (e.g. the
+ "watched" pill under the watched-toggle): visible at rest, faded when the group is hovered, and
+ dropped entirely on touch — where the toggle is always present, so the two would otherwise collide
+ at the same corner. Requires a `.group` ancestor (Tailwind's group marker). */
+.reveal-idle {
+ transition: opacity 150ms ease;
+}
+@media (hover: hover) {
+ .reveal {
+ opacity: 0;
+ transition: opacity 150ms ease;
+ }
+ .group:hover .reveal,
+ .group:focus-within .reveal,
+ .reveal:focus-visible {
+ opacity: 1;
+ }
+ .group:hover .reveal-idle {
+ opacity: 0;
+ }
+}
+@media (hover: none) {
+ .reveal-idle {
+ display: none;
+ }
+}
+
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
chrome (nav/header) barely reads — there's no content behind it to refract, unlike dialogs
which sit over the feed. Strengthen the pools and let a touch more bleed through the glass.