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:
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user