fix(e4): address deep-review findings — bookmark popstate, focus trap, touch a11y

Deep-review (6 dimensions, 3-verifier adversarial pass) surfaced 7 findings, all
verified; fixed here.

- NavigationProvider + history.ts (medium, correctness): the popstate handlers
  restored the open channel / Plex sub-view from history.state ONLY, but a fresh
  ?chan / ?v bookmark lives in the URL with no marker in state — so any overlay's
  programmatic history.back() (closing a modal/player) silently closed the
  bookmarked channel or collapsed the sub-view to the grid. Give onPop the same
  URL fallback the initializers already use (history.ts extracts a shared readView
  so seed and popstate can't diverge again).
- plexCards SeasonCard (medium, a11y): the "In progress" season badge was a
  reveal-idle swap-badge → display:none on touch, and a season has no progress bar,
  so an in-progress season was indistinguishable from unwatched on touch. Moved it
  to the top-left (the toggle owns top-right) and made it always visible.
- plexCards watched toggle (low, a11y / WCAG 1.4.1): watched state was color-only
  on touch (green vs white CheckCircle2). New WatchedIcon distinguishes by SHAPE —
  CheckCircle2 (watched) vs an empty Circle (not) — used by all three card toggles.
- Modal.tsx (low, a11y): the focus trap leaked on Shift+Tab when focus rested on
  the panel container itself (Node.contains(self) is true, so the pull-back branch
  never ran). Guard now also checks `active === panel`.
- index.css (low, a11y): .reveal-idle faded only on .group:hover though .reveal is
  revealed on hover AND focus-within → the badge overlapped the control on keyboard
  focus. Added the focus-within fade.
- PlexBrowse.tsx (low, correctness): the ?v=playlist decoder accepted a non-integer
  id (Number.isFinite); tightened to Number.isInteger(n) && n > 0, matching the ?pl
  guard.

Gate green (tsc, knip, lint 0-err, 247 vitest). Self-E2E (localdev): ?v=show and
?chan bookmarks survive a modal open+close (were collapsing/closing before); Modal
Shift+Tab from the panel stays inside; in-progress season badge visible on touch;
watched toggle shows an empty-circle-vs-check shape difference; 0 console errors.
This commit is contained in:
2026-08-20 03:26:19 +02:00
parent 1579a81395
commit 23ac884b42
6 changed files with 56 additions and 25 deletions
+4 -2
View File
@@ -73,8 +73,10 @@ export default function Modal({
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.
if (!panel.contains(active) || active === panel) {
// Focus escaped the dialog, OR sits on the panel container itself (tabIndex=-1, reached by
// clicking dialog dead space) — Node.contains(self) is true, so `active === panel` must be
// checked explicitly or Shift+Tab from the panel would leak to the background. Pull it back.
e.preventDefault();
(e.shiftKey ? last : first).focus();
} else if (e.shiftKey && active === first) {
@@ -244,7 +244,14 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
const chan = e.state?._chan as string | undefined;
// Fall back to the ?chan URL param (the browser restored the landed entry's URL) when the entry
// has no _chan — a fresh ?chan bookmark's entry is URL-only, and reading e.state alone would
// wrongly close the channel when a popstate lands here (e.g. after an overlay's programmatic
// history.back()). Mirrors the channelView initializer's URL fallback.
const chan =
(e.state?._chan as string | undefined) ??
new URLSearchParams(window.location.search).get("chan") ??
undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
}
window.addEventListener("popstate", onPop);
+6 -2
View File
@@ -77,8 +77,12 @@ const PLEX_SUB_URL: SubviewUrl<Sub> = {
return arg ? { kind: "info", id: arg } : null;
case "person":
return arg ? { kind: "person", name: arg } : null;
case "playlist":
return arg && Number.isFinite(Number(arg)) ? { kind: "playlist", id: Number(arg) } : null;
case "playlist": {
// Positive-integer id only — a fractional/zero/negative ?v=playlist:<arg> would open a
// sub-view for a non-existent id (matches the ?pl guard in PlaylistsProvider.readSelectedId).
const id = arg ? Number(arg) : NaN;
return Number.isInteger(id) && id > 0 ? { kind: "playlist", id } : null;
}
case "season": {
const [showId, seasonId] = arg.split(":");
return showId && seasonId ? { kind: "season", showId, seasonId } : null;
+21 -13
View File
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react";
import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
@@ -24,6 +24,18 @@ const PLAYABLE_TINT: Record<string, string> = {
const swapBadgeReveal = (hasControl: boolean): string =>
hasControl ? "reveal-idle" : "group-hover:opacity-0";
// Watched-toggle icon. Distinguishes state by SHAPE (a filled check vs an empty circle), not color
// alone — on touch the toggle is the only surviving watched indicator (the text pill is hidden), so a
// green-vs-white CheckCircle2 would be a use-of-color (WCAG 1.4.1) gap. Watched = CheckCircle2 (green),
// not-watched = Circle (empty, white).
function WatchedIcon({ watched }: { watched: boolean }) {
return watched ? (
<CheckCircle2 className="w-4 h-4 text-success" />
) : (
<Circle className="w-4 h-4 text-white" />
);
}
export function PlexPosterCard({
card,
onOpen,
@@ -90,9 +102,7 @@ export function PlexPosterCard({
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 reveal hover:bg-black/80 transition"
>
<CheckCircle2
className={`w-4 h-4 ${card.status === "watched" ? "text-success" : "text-white"}`}
/>
<WatchedIcon watched={card.status === "watched"} />
</button>
)}
{/* Media info page (movies/episodes). */}
@@ -219,7 +229,7 @@ export function SeasonCard({
}
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
>
<CheckCircle2 className={`w-4 h-4 ${watched ? "text-success" : "text-white"}`} />
<WatchedIcon watched={watched} />
</button>
)}
{/* Add whole season to a playlist, on hover. */}
@@ -244,12 +254,12 @@ export function SeasonCard({
{t("plex.watched")}
</span>
)}
{/* In-progress lives at the top-LEFT (the watched-toggle owns the top-right) and stays visible
on touch: unlike movie/episode cards, a season has no progress bar, and the always-shown
toggle reads as unwatched for a partial season — so this badge is the sole in-progress cue
and must not be a reveal-idle swap-badge (which display:none-s on touch). */}
{se.status === "in_progress" && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
!!onToggleWatched,
)}`}
>
<span className="pointer-events-none absolute top-1.5 left-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm">
{t("plex.inProgress")}
</span>
)}
@@ -430,9 +440,7 @@ export function EpisodeCard({
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 reveal hover:bg-black/80 transition"
>
<CheckCircle2
className={`w-4 h-4 ${ep.status === "watched" ? "text-success" : "text-white"}`}
/>
<WatchedIcon watched={ep.status === "watched"} />
</button>
)}
{ep.status === "watched" && (
+5 -1
View File
@@ -558,7 +558,11 @@ body {
.reveal:focus-visible {
opacity: 1;
}
.group:hover .reveal-idle {
/* Fade the idle badge whenever its companion control is revealed — on hover AND on keyboard
focus-within (the `.reveal` trigger set above lists both), else the two overlap at the same
corner for a keyboard user. */
.group:hover .reveal-idle,
.group:focus-within .reveal-idle {
opacity: 0;
}
}
+12 -6
View File
@@ -32,8 +32,12 @@ export function useHistorySubview<T>(
open: (next: T) => void;
back: () => void;
} {
const [view, setView] = useState<T>(() => {
// Reload of an in-app entry: history.state._sub. Fresh bookmark: decode the ?<key> param.
// Resolve the current entry's sub-view: history.state._sub for an in-app entry, else decode the
// ?<key> URL param for a fresh bookmark (whose entry carries the param but no _sub), else root.
// ⚠️ The seed AND the popstate handler must use THIS same fallback: a popstate can land on a
// bookmark entry (URL-only, no _sub) — e.g. after an overlay's programmatic history.back() — and
// reading history.state alone would wrongly collapse the bookmarked sub-view to root.
const readView = (): T => {
const fromState = window.history.state?._sub as T | undefined;
if (fromState != null) return fromState;
if (url) {
@@ -42,13 +46,15 @@ export function useHistorySubview<T>(
if (decoded != null) return decoded;
}
return root;
});
};
const readViewRef = useRef(readView);
readViewRef.current = readView;
const [view, setView] = useState<T>(readView);
useEffect(() => {
// Back/Forward restores the sub-view from history.state (the browser restores each entry's URL too).
const onPop = () => setView((window.history.state?._sub as T) ?? root);
// Back/Forward restores the sub-view from the landed entry (state OR its restored ?<key> URL).
const onPop = () => setView(readViewRef.current());
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
view,