feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)

- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
  Home tile; the meta line pinned to the bottom edge, the length a badge on
  the picture, the media Play only where the click plays, the hover row at
  the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
  Home shelves are landscape; rails turn content-visibility off (a card's
  reserved height had become the strip's). Home slot 232px, so the Play
  and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
  quarters wide and centred; channel / playlist tiles get Play, open and
  "…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
  title, facts and synopsis (popover `pointerOnly`); the synopsis and title
  tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
  date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
  an empty column is not drawn); filtering values are chips there and on
  the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
This commit is contained in:
2026-09-27 00:54:19 +02:00
parent 6653d81a80
commit a453b77f04
30 changed files with 1882 additions and 769 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return (
<>
{ch.description ? (
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)}
</p>
) : (
+56 -1
View File
@@ -11,7 +11,7 @@
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -785,3 +785,58 @@ describe("Home · keyboard", () => {
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
});
});
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
// at a readable measure.
describe("Home's hero text", () => {
const film = {
source: "plex",
kind: "movie",
id: "m1",
title: "A Film",
subtitle: "2003",
thumb: null,
aspect: "poster",
duration_seconds: 9240,
position_seconds: 0,
remaining_seconds: null,
status: "new",
video: null,
plex: {
id: "m1",
type: "movie",
title: "A Film",
thumb: "",
year: 2003,
rating: 7.8,
summary: "What the film is about.",
},
};
it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => {
discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } });
mount();
const title = await screen.findByRole("heading", { name: "A Film" });
const text = title.parentElement!;
expect(within(text).getByText(/^2003 · \S/)).toBeTruthy();
expect(within(text).getByText("7.8")).toBeTruthy();
// the subtitle was the year alone: it is not printed a second time
expect(within(text).queryByText("2003")).toBeNull();
expect(within(text).getByText("What the film is about.").className).toContain("measure");
expect(title.closest("section")!.className).toContain("lg:w-3/4");
});
it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => {
discover.mockResolvedValue({
...payload("v1"),
hero: { tier: "surprise", card: ytCard("v9", "A video") },
continue_watching: [],
});
mount();
const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!;
expect(within(text).getByText("A channel")).toBeTruthy();
expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy();
expect(text.querySelector("p.measure")).toBeNull();
});
});
+35 -1
View File
@@ -65,7 +65,13 @@ vi.mock("../lib/api", () => ({
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
}));
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
const openChannel = vi.fn();
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({
setPage: vi.fn(),
openChannel: (...a: unknown[]) => openChannel(...a),
}),
}));
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home";
@@ -150,6 +156,7 @@ const item = (label: string) => {
};
beforeEach(() => {
openChannel.mockReset();
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, "");
discover.mockReset().mockResolvedValue(payload());
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([
"home.hero.play",
// A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers).
"card.menu.openChannel",
"home.shelf.moveFirst",
"home.shelf.moveLeft",
"home.shelf.moveRight",
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
expect(item("home.pin.add").disabled).toBe(true);
});
});
// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere.
describe("Home — a card's own actions", () => {
it("opens a pinned channel from its tile's ↗", async () => {
mount();
await screen.findByText("Comfort B");
fireEvent.click(
screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement,
);
expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B");
});
it("gives the hero the card's whole menu, not the pin row alone", async () => {
discover.mockResolvedValue({
...payload(),
hero: { tier: "surprise", card: base("h1", "Hero video") },
});
mount();
fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" }));
const rows = labels();
expect(rows[0]).toBe("home.hero.play");
expect(rows).toContain("card.markWatched");
expect(rows).toContain("card.menu.openChannel");
});
});
+407 -161
View File
@@ -6,9 +6,11 @@ import {
ChevronRight,
ChevronsLeft,
ChevronsRight,
ArrowUpRight,
PinOff,
Play,
RotateCcw,
Star,
} from "lucide-react";
import { qk } from "../lib/queryKeys";
import {
@@ -26,10 +28,9 @@ import {
} from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
import { formatDuration } from "../lib/format";
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
import { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
@@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
import VideoCard, { VideoHoverActions } from "./VideoCard";
import { useVideoActions } from "./useVideoActions";
import { usePlexCardActions } from "./usePlexCardActions";
import { PlexPosterCard } from "./plexCards";
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
import { usePlaylistsActions } from "./playlistsContext";
import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
// The Home surface (E24) — "Mit nézzek most?".
//
@@ -68,11 +83,12 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
// silently kept offering four.
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
const YT_SLOT = 200;
const YT_SLOT = 232;
// How many already-offered cards the reroll remembers. Matched to the server's own cap
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
@@ -89,6 +105,151 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
["last", "home.shelf.moveLast", ChevronsRight],
];
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
* channel's or a playlist's open row), then the shelf's rows. */
function useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving = false,
}: {
cards: DiscoverCard[];
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const openPage = useOpenShelfPage();
const target = menu.target;
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
const pinned = target ? (target.pinned_from ?? target) : null;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.kind === "channel" || target.kind === "playlist"
? [{ ...openPage.item(target), separatorBefore: true }]
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
];
return {
menu,
items,
panels: (
<>
{videoActions.panel}
{plexActions.panel}
</>
),
plexAdd: plexActions.add,
};
}
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
function useOpenShelfPage() {
const { t } = useTranslation();
const { openChannel, setPage } = useNavigationActions();
const { setSelectedId } = usePlaylistsActions();
const open = (card: DiscoverCard) => {
if (card.kind === "channel") openChannel(card.id, card.title);
else {
setSelectedId(Number(card.id));
setPage("playlists");
}
};
const label = (card: DiscoverCard) =>
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
return {
open,
label,
item: (card: DiscoverCard): ActionMenuItem => ({
id: "open",
label: label(card),
icon: <ArrowUpRight className="w-4 h-4" />,
onSelect: () => open(card),
}),
};
}
function Rail({
title,
ariaLabel,
@@ -118,83 +279,16 @@ function Rail({
/** A reorder is being written: the move rows wait for it (one write at a time). */
moveSaving?: boolean;
}) {
const { t } = useTranslation();
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
const menu = useContextMenu<DiscoverCard>();
const pin = useComfortPin(menu.open);
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
// Play row (it resolves `next`) and its shelf rows around it.
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
const plexActions = usePlexCardActions(
{
onOpen: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onPlay(c);
},
onInfo: (p) => {
const c = cards.find((x) => x.plex === p);
if (c) onInfo(c);
},
onToggleWatched,
},
menu.open,
);
const target = menu.target;
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
const items: ActionMenuItem[] = !target
? []
: [
{
id: "play",
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
icon: <Play className="w-4 h-4" />,
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
// than dropped, so the menu does not change shape from tile to tile.
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
onSelect: () => onPlay(target),
},
...(target.video
? videoActions
.items(target.video, menu.anchor, { pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: target.plex
? plexActions
.items(target.plex, { play: false, pin: false })
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
: []),
...(onMove && target.pin_id != null
? [
...SHELF_MOVES.map(([to, key, Icon], i) => ({
id: `shelf-${to}`,
label: t(key),
icon: <Icon className="w-4 h-4" />,
separatorBefore: i === 0,
// The same rule the move itself runs, so a row is never enabled onto a no-op.
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
onSelect: () => onMove(target, to, cards),
})),
{
id: "shelf-unpin",
label: t("home.pin.remove"),
icon: <PinOff className="w-4 h-4" />,
separatorBefore: true,
// The only row here that removes a curated entry, and it sat in the same colour as
// the four harmless reorders. `danger` matches the shared unpin row in
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
// rail below) composes for the same command — the two must not disagree.
danger: true,
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
// other surface it can never be in the "not known yet" state.
onSelect: () => pin.remove(target.pin_id as number, target.title),
},
]
: pin.item(pinTargetForCard(target), target.title)),
];
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onMove,
moveSaving,
});
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
@@ -221,13 +315,19 @@ function Rail({
{cards.map((c, i) => (
<div
key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
className="grid shrink-0 grid-cols-1"
style={{ width: YT_SLOT }}
>
{c.video ? (
<VideoCard
video={c.video}
view="cardsSmall"
shelf
width={YT_SLOT}
navIndex={i}
onState={onState}
@@ -240,23 +340,33 @@ function Rail({
) : c.plex ? (
<PlexPosterCard
card={c.plex}
wide
shelf
navIndex={i}
onOpen={() => onPlay(c)}
onToggleWatched={onToggleWatched}
onAdd={plexActions.add}
onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
<ShelfTile
card={c}
onPlay={onPlay}
menuProps={menuFor[i]?.()}
menuButtonProps={buttonFor[i]?.()}
menuOpen={menu.open && menu.target === c}
navIndex={i}
/>
)}
</div>
))}
</Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{videoActions.panel}
{plexActions.panel}
{panels}
</section>
);
}
@@ -268,45 +378,90 @@ function ShelfTile({
card,
onPlay,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
menuProps?: Record<string, unknown>;
/** The rail menu's "…" for this tile. */
menuButtonProps?: Record<string, unknown>;
menuOpen?: boolean;
navIndex?: number;
}) {
const { t } = useTranslation();
const openPage = useOpenShelfPage();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel =
card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { count: count ?? 0 });
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
// picture is the play button, the count the meta line pinned to the frame's bottom.
return (
<Pressable
selectable
onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
// alive to all three input paths.
aria-disabled={!card.next}
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`}
>
<span className="block aspect-video bg-card overflow-hidden">
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
) : null}
</span>
<span className="block p-2">
<span className="block text-xs font-medium truncate">{card.title}</span>
<span className="block text-2xs text-muted">{countLabel}</span>
</span>
</Pressable>
<MediaCard
dense
rootProps={{
...menuProps,
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className={!card.next ? "opacity-60" : undefined}
picture={
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
// on the stretched frame itself that would be the whole card's height.
<div className={mediaPictureBox("wide")}>
<Pressable
onClick={() => card.next && onPlay(card)}
aria-label={card.title}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
// playable, but it IS the tile you most want to take off the shelf, and the only way to
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
// handler guards), alive to all three input paths.
aria-disabled={!card.next}
data-nav-open=""
className={MEDIA_PICTURE}
>
{card.thumb ? (
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
) : null}
{/* The Play only when there is something to play: the click plays the channel's newest
unwatched video, the playlist's first. */}
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
</Pressable>
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
<IconButton
variant="media"
onClick={() => openPage.open(card)}
label={openPage.label(card)}
>
<ArrowUpRight />
</IconButton>
{menuButtonProps && (
<MoreButton
variant="media"
label={t("card.menu.more")}
expanded={menuOpen}
menuProps={menuButtonProps}
/>
)}
</div>
</div>
}
title={
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
{card.title}
</span>
}
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
moreLabel={t("card.menu.more")}
/>
);
}
@@ -314,19 +469,34 @@ function Hero({
tier,
card,
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onReroll,
rerolling,
}: {
tier: HeroTier;
card: DiscoverCard;
onPlay: (card: DiscoverCard) => void;
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
onInfo: (card: DiscoverCard) => void;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onToggleWatched: (card: PlexCard) => void;
onReroll: () => void;
rerolling: boolean;
}) {
const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
// answered with — so it answers the same right-click every card in the app does.
const pinMenu = useContextMenu<DiscoverCard>();
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards: [card],
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
});
const started = card.position_seconds > 0;
const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
@@ -340,41 +510,111 @@ function Hero({
? t("home.hero.comfort")
: t("home.hero.surprise");
return (
<section
{...pinMenu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`}
>
<Pressable
onClick={() => onPlay(card)}
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
}`}
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
{card.duration_seconds != null && (
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</Pressable>
// One line of facts under the title: a film's year, length and score; a video's views and age.
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
// its hero as well as a film does (the owner's pick, 2026-09-26).
const plex = card.plex;
const video = card.video;
const facts = plex
? [plex.year, formatRuntime(card.duration_seconds)]
: video
? // The channel is the subtitle already.
[
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
relativeTime(video.published_at),
]
: [];
const factLine = facts.filter(Boolean).join(" · ");
// A feed video carries no description, so only a film or an episode has a summary line.
const summary = plex?.summary;
// A film's subtitle is its year, which the fact line already says.
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
<div className="flex min-w-0 flex-col">
return (
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
// a narrower window gives it the whole width back. The padding is even on every side.
<section
{...menu.triggerProps(card)}
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
>
<div
className={`group relative shrink-0 ${
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
}`}
>
<Pressable
onClick={() => onPlay(card)}
className="relative block h-full w-full overflow-hidden rounded-lg border border-border bg-card"
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
>
{card.thumb ? (
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
) : null}
{card.duration_seconds != null && (
// It steps aside while the hover row covers its corner, as a card's length does.
<span
className={`absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums ${HOVER_YIELD}`}
>
{formatDuration(card.duration_seconds)}
</span>
)}
{started && card.duration_seconds ? (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph />
</div>
</Pressable>
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
{card.video ? (
<div className={MEDIA_BAR}>
<VideoHoverActions
video={card.video}
onState={onState}
onToggleSave={onToggleSave}
media
more={{ props: menu.buttonProps(card), open: menu.open }}
/>
</div>
) : card.plex ? (
<PlexHoverBar
card={card.plex}
onToggleWatched={onToggleWatched}
onAdd={plexAdd}
moreProps={menu.buttonProps(card)}
moreOpen={menu.open}
/>
) : null}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
{(factLine || plex?.rating != null) && (
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
{factLine && <span>{factLine}</span>}
{plex?.rating != null && (
<span className="inline-flex items-center gap-0.5">
{factLine && <span aria-hidden>·</span>}
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
{plex.rating.toFixed(1)}
</span>
)}
</p>
)}
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
<div className="flex flex-wrap items-center gap-2 pt-4">
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")}
</Button>
@@ -386,16 +626,18 @@ function Hero({
>
{t("home.hero.reroll")}
</Button>
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
beside a card's title. */}
<MoreButton
className="touch-only"
label={t("card.menu.more")}
expanded={menu.open}
menuProps={menu.buttonProps(card)}
/>
</div>
</div>
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
The owner's call (2026-09-19): the row unpins the show. */}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
title={(c) => (c.pinned_from ?? c).title}
/>
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
{panels}
</section>
);
}
@@ -776,6 +1018,10 @@ export default function Home() {
tier={hero.tier}
card={hero.card}
onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onReroll={() => reroll()}
rerolling={rerolling}
/>
@@ -326,7 +326,7 @@ export default function PlexCollectionView({
{d.summary && (
<div className="mt-3">
<p
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
className={`measure text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
>
{d.summary}
</p>
+2 -1
View File
@@ -70,8 +70,9 @@ function CardRail({
keyboardNav
>
{cards.map((c, i) => (
<div key={c.id} className="w-37.5 shrink-0">
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
shelf
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
+58 -1
View File
@@ -5,7 +5,7 @@
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -156,3 +156,60 @@ describe("PlexInfo enriched detail (page)", () => {
expect(screen.getByText("IMDb")).toBeTruthy();
});
});
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
describe("PlexInfo layout B (page)", () => {
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
// The strips are Carousels, which watch their size.
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
const heading = (name: string) => screen.getByRole("heading", { name });
const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement;
const full: PlexItemDetail = {
...detail,
cast: [{ name: "An Actor", role: "Hero" }],
collections: [
{
id: "c1",
title: "The Collection",
source: "plex",
items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }],
},
],
};
it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
wrap(<PlexInfo detail={full} variant="page" />);
const coll = heading("The Collection");
const crew = heading("plex.info.crew");
const cast = heading("plex.info.cast");
const facts = heading("plex.info.facts.title");
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(coll, crew) && follows(crew, cast)).toBe(true);
const main = column(coll);
expect(main.contains(crew) && main.contains(cast)).toBe(true);
const side = column(facts);
expect(side).not.toBe(main);
expect(side).toBe(column(heading("plex.info.keywords")));
// the two columns are the two children of the one grid
expect(main.parentElement!.className).toContain(TWO_COLUMNS);
expect([...main.parentElement!.children]).toEqual([main, side]);
});
it("with nothing for the main column, the sidebar's sections take the full width", () => {
wrap(<PlexInfo detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }} variant="page" />);
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
const side = column(heading("plex.info.facts.title"));
expect(side).toBe(column(heading("plex.info.keywords")));
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
expect([...side.parentElement!.children]).toEqual([side]);
});
});
+172 -183
View File
@@ -22,6 +22,8 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
import clsx from "clsx";
import { PlexPosterCard } from "./plexCards";
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
@@ -156,6 +158,7 @@ function RatingsRow({
// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries,
// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something.
// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule).
function FactsPanel({
detail,
onFilter,
@@ -175,9 +178,9 @@ function FactsPanel({
label: t("plex.info.facts.release"),
node: (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
className="hover:text-accent"
>
{year}
</Filterable>
@@ -195,10 +198,7 @@ function FactsPanel({
key: "cert",
label: t("plex.info.facts.cert"),
node: (
<Filterable
onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}
className="hover:text-accent"
>
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
{cert}
</Filterable>
),
@@ -212,17 +212,15 @@ function FactsPanel({
key: "studios",
label: t("plex.info.facts.studios"),
node: (
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
{studios.map((s, i) => (
<span key={s}>
<Filterable
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
className="hover:text-accent"
>
{s}
</Filterable>
{i < studios.length - 1 ? "," : ""}
</span>
<span className="flex flex-wrap gap-1.5">
{studios.map((s) => (
<Filterable
key={s}
tag
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
>
{s}
</Filterable>
))}
</span>
),
@@ -237,7 +235,7 @@ function FactsPanel({
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
{rows.map((r) => (
<div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt>
@@ -291,9 +289,8 @@ function CrewPanel({
return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{/* In the main column (layout B): as many name columns as its width holds. */}
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
@@ -536,7 +533,8 @@ export default function PlexInfo({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={
onFilter
@@ -549,7 +547,8 @@ export default function PlexInfo({
)}
{detail.content_rating && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
onClick={
onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] })
@@ -564,7 +563,8 @@ export default function PlexInfo({
) : null}
{detail.studio && (
<Filterable
className={mutedCls}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
>
{detail.studio}
@@ -608,25 +608,24 @@ export default function PlexInfo({
)}
{detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "}
{detail.directors.map((d, i) => (
<span key={d}>
<Filterable
className="font-medium"
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
{i < detail.directors.length - 1 ? ", " : ""}
</span>
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
<span>{t("plex.info.director")}:</span>
{detail.directors.map((d) => (
<Filterable
key={d}
tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
>
{d}
</Filterable>
))}
</p>
)}
{detail.summary && (
<p
className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
>
{detail.summary}
</p>
@@ -679,100 +678,18 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
{showKeywords && (
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
)}
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
)}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
</div>
{c.role && (
<div
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</div>
)}
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
{detail.collections
?.filter((col) => !hiddenSources.includes(col.source))
.map((col) => (
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
strips first (the owner's most used), then the crew, then the cast — beside a narrow
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
toggleable, so the layout is built from what is ON: a column with nothing in it is not
rendered and the other takes the full width; inside a column the sections stack, so a
hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
overlay (over the running player) stays lean: its strips and its cast, stacked. */}
{(() => {
const collections = (detail.collections ?? []).filter(
(col) => !hiddenSources.includes(col.source),
);
const strips = collections.map((col) => (
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}>
<div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
@@ -795,62 +712,134 @@ export default function PlexInfo({
</div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => (
<Pressable
selectable
key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem}
className="group/col w-28 shrink-0"
>
<div
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
}`}
>
<img
src={m.thumb}
alt=""
className="h-full w-full object-cover transition group-hover/col:scale-105"
/>
{m.status === "watched" && (
<span className="absolute right-1 top-1 rounded-sm bg-black/70 px-1 text-[0.5625rem] text-white">
✓
</span>
)}
{onPlayItem && (
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
<Play className="h-8 w-8 text-white" fill="currentColor" />
</div>
)}
{(m.position_seconds ?? 0) > 0 &&
m.status !== "watched" &&
m.duration_seconds ? (
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
<div
className="h-full bg-accent"
style={{
width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%`,
}}
/>
</div>
) : null}
</div>
<div
className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}
>
{m.title}
</div>
{m.year ? (
<div className={`text-2xs ${overlay ? "text-white/55" : "text-muted"}`}>
{m.year}
</div>
) : null}
</Pressable>
// The one media card, as on every other strip (E28 S3h). A grid cell, so the
// card fills the slot the carousel stretches to its tallest card.
<div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
card={{ ...m, type: "movie" }}
shelf
navIndex={i}
onOpen={() => onPlayItem?.(m.id)}
/>
</div>
))}
</Carousel>
</div>
))}
));
const castRow = cast.length > 0 && (
<div className={overlay ? "mt-5" : `${SURFACE.panel} p-4 sm:p-5`}>
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")}
</h2>
<Carousel
ariaLabel={t("plex.info.cast")}
gap="gap-3"
keyboardNav={!!(onOpenPerson || onFilter)}
>
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
) : (
<div
className={`grid h-full w-full place-items-center text-lg opacity-40 ${overlay ? "text-white" : ""}`}
>
{c.name.charAt(0)}
</div>
)}
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
</div>
{c.role && (
<div
className={`truncate text-2xs ${overlay ? "text-white/55" : "text-muted"}`}
>
{c.role}
</div>
)}
</>
);
return clickable ? (
<Pressable
selectable
key={i}
align="center"
{...navItemAttrs(i)}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0"
>
{inner}
</Pressable>
) : (
<div key={i} className="w-20 shrink-0 text-center">
{inner}
</div>
);
})}
</Carousel>
</div>
);
if (overlay)
return (
<>
{strips}
{castRow}
</>
);
const crewBox = showCrew && hasCrew && (
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
);
const factsBox = showFacts && hasFacts && (
<FactsPanel detail={detail} onFilter={onFilter} />
);
const keywordsBox = showKeywords && hasKeywords && (
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
);
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
const hasSide = !!factsBox || !!keywordsBox;
if (!hasMain && !hasSide) return null;
return (
<div
className={clsx(
"grid items-start gap-3",
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
)}
>
{hasMain && (
<div className="flex min-w-0 flex-col gap-3">
{strips}
{crewBox}
{castRow}
</div>
)}
{hasSide && (
<div className="flex min-w-0 flex-col gap-3">
{factsBox}
{keywordsBox}
</div>
)}
</div>
);
})()}
</div>
{editorOpen && library && (
<PlexCollectionEditor
+8 -3
View File
@@ -1,12 +1,17 @@
import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav";
import { MEDIA_IMG } from "./ui/mediaCardStyles";
import { Pressable } from "./ui/Pressable";
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never
// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles.
//
// Hover = the media card's (E28 S3h): the shadow lifts and the art zooms inside its half, plus the
// accent edge glass-hover gives a pressable panel. It had `glass-hover` on a tile with no border, so
// the only visible change was a shadow under a colour band — the owner read it as no hover at all.
export default function PosterTile({
title,
subtitle,
@@ -32,11 +37,11 @@ export default function PosterTile({
onClick={onClick}
title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl transition ${className ?? ""}`}
className={`group relative flex flex-col overflow-hidden rounded-xl border border-border shadow-md transition hover:border-accent/55 hover:shadow-2xl ${className ?? ""}`}
>
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
<div
className="grid h-1/2 min-h-0 grid-cols-2"
className="grid h-1/2 min-h-0 grid-cols-2 overflow-hidden"
aria-hidden
style={posters.length === 0 ? { background: band } : undefined}
>
@@ -48,7 +53,7 @@ export default function PosterTile({
src={`/api/plex/image/${posters[i]}`}
alt=""
loading="lazy"
className="h-full w-full object-cover"
className={MEDIA_IMG}
/>
) : (
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
@@ -91,3 +91,65 @@ describe.each(Object.keys(FEED_VIEW_SPEC) as FeedView[])("the %s view's actions"
}
});
});
// E28 S3h, the owner's pick B (2026-09-26): the compact row had a leading action column that sat
// empty at rest. Now the title leads, the "…" is always at the row's end, and on hover the quick
// toggles float in beside it over the date, which steps aside.
describe("the compact row (rowsCompact)", () => {
it("leads with the title and ends with the '…'; the toggles reveal beside it over a yielding date", () => {
renderCard("rowsCompact");
const row = screen.getByTestId("video-card");
expect(row.firstElementChild!.textContent).toBe("Cicada 3301");
const more = screen.getByRole("button", { name: "card.menu.more" });
// the layer that floats left of the "…" (right-full) and shows on hover only
const toggles = screen
.getByRole("button", { name: "card.markWatched" })
.closest<HTMLElement>(".right-full");
expect(toggles!.className).toContain("reveal-hover");
// the toggles and the "…" share the row's last slot (the resume bar is drawn after it)
const slot = more.parentElement!;
expect(slot.contains(toggles)).toBe(true);
const inFlow = [...row.children].filter((c) => !c.className.includes("absolute"));
expect(inFlow[inFlow.length - 1]).toBe(slot);
expect(row.querySelector(".w-48")!.className).toContain("group-hover:opacity-0");
});
});
// On a shelf (Home's strips) the card is one title line over the channel alone, so every card on
// the strip is the same height (E28 S3h, the owner's shelf density).
describe("the card on a shelf", () => {
function renderSmall(shelf: boolean) {
render(
<QueryClientProvider client={new QueryClient()}>
<VideoCard
video={video}
view="cardsSmall"
width={0}
shelf={shelf}
onState={() => {}}
onToggleSave={() => {}}
/>
</QueryClientProvider>,
);
return screen.getByTestId("video-card");
}
const titleOf = (card: HTMLElement) =>
[...card.querySelectorAll("*")].find(
(e) => e.childElementCount === 0 && e.textContent === "Cicada 3301",
)!;
it("cuts the title to one line and drops the views · age line", () => {
const card = renderSmall(true);
const title = titleOf(card).closest(".font-medium")!;
expect(title.className).toContain("truncate");
expect(title.className).not.toContain("line-clamp-2");
expect(card.querySelector("span[title]")).toBeNull();
expect(screen.getByRole("link", { name: "LEMMiNO" })).toBeTruthy();
});
it("off a shelf keeps two title lines and the age", () => {
const card = renderSmall(false);
expect(titleOf(card).closest(".font-medium")!.className).toContain("line-clamp-2");
expect(card.querySelector("span[title]")).not.toBeNull();
});
});
+140 -76
View File
@@ -27,6 +27,15 @@ import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "./ui/MediaCard";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -35,7 +44,8 @@ type More = { props: Record<string, unknown>; open: boolean };
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
function Actions({
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
export function VideoHoverActions({
video,
onState,
onToggleSave,
@@ -64,8 +74,8 @@ function Actions({
onToggleSave(video.id, !video.saved);
};
const variant = media ? "media" : "ghost";
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
// one-line row's w-32 column (ROW_COL.actions).
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
return (
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton
@@ -171,7 +181,7 @@ function RowMetaCells({ video }: { video: Video }) {
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
bare span has no role, and screen readers don't dependably surface title on one), while a
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
in Actions — the label must not tell anyone to click something that doesn't respond. */}
in VideoHoverActions — the label must not tell anyone to click something that doesn't respond. */}
<span>
{StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}>
@@ -220,7 +230,10 @@ function Thumb({
};
return (
<div className={clsx("relative", className)}>
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
<div className={clsx("relative min-h-0", className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10" (E28 S3d4). */}
<a
@@ -229,7 +242,7 @@ function Thumb({
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className="block relative h-full w-full rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow"
className={MEDIA_PICTURE}
>
{video.thumbnail_url ? (
<img
@@ -239,7 +252,7 @@ function Thumb({
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
className={MEDIA_IMG}
/>
) : (
<div className="w-full h-full" />
@@ -247,7 +260,8 @@ function Thumb({
{video.duration_seconds != null ? (
<span
className={clsx(
"absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm",
MEDIA_BADGE,
"bottom-1.5 right-1.5",
// The hover row takes this corner; the duration steps aside while it shows.
bar && HOVER_YIELD,
)}
@@ -278,7 +292,7 @@ function Thumb({
`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 && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
<div className={MEDIA_HOVER_LAYER}>
{inProgress ? (
<>
{small ? (
@@ -338,15 +352,9 @@ function Thumb({
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
<MediaProgress pct={pct} />
</a>
{bar && (
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
)}
{bar && <div className={MEDIA_BAR}>{bar}</div>}
</div>
);
}
@@ -359,7 +367,7 @@ function Thumb({
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
more: 28, // the "…" at the row's end (size-7); the quick actions float over the date on hover
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
@@ -373,7 +381,7 @@ const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
const ROW_BASE = ROW_PAD + ROW_TITLE_MIN + ROW_GAP + ROW_COL.more;
const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
@@ -401,6 +409,7 @@ function VideoCard({
menuButtonProps,
menuOpen = false,
navIndex,
shelf = false,
}: {
video: Video;
view: FeedView;
@@ -422,6 +431,8 @@ function VideoCard({
menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
shelf?: boolean;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
@@ -493,8 +504,8 @@ function VideoCard({
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
// min-h-6: a one-line title is still a 24 target (B1).
"min-h-6 select-text font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2",
"min-h-6 select-text hover:text-accent",
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
)}
>
{video.title}
@@ -523,7 +534,7 @@ function VideoCard({
// the toggles inside it are gone and only the "…" is left.
const actions = (
<div className="reveal">
<Actions
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
@@ -569,29 +580,53 @@ function VideoCard({
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
<div className="shrink-0 w-32">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
filter panel are doing. Until the first measure lands (width 0) show everything: the
row is off-screen for that frame anyway, and guessing narrow would flash. */}
{/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
end holds a faint "…" (always there: the menu's door on every device), and on hover or
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
Everything but the title is shrink-0, so the title is the only thing that can give —
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
Columns drop from the right as the row narrows, least important first: a title with no
date beats a date with no title. `width` is the ROW's own, so this holds whatever the
rail and the filter panel are doing. Until the first measure lands (width 0) show
everything: the row is off-screen for that frame anyway, and guessing narrow would
flash. */}
<div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{fits(ROW_FITS.meta) && (
<div className={clsx(!fits(ROW_FITS.views) && HOVER_YIELD)}>
<RowMetaCells video={video} />
</div>
)}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && (
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
<div
className={clsx(
"shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate",
!fits(ROW_FITS.published) && HOVER_YIELD,
)}
>
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
<div className={clsx("shrink-0 w-48 text-sm text-muted truncate", HOVER_YIELD)}>
{published}
</div>
)}
<div className="relative shrink-0">
<div
className={clsx(HOVER_REVEAL, "absolute inset-y-0 right-full mr-1 flex items-center")}
>
<VideoHoverActions video={video} onState={onState} onToggleSave={onToggleSave} />
</div>
{more && (
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
)}
</div>
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
@@ -649,56 +684,85 @@ function VideoCard({
}
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
// of its own), and on touch its "…" sits beside the title as a 44px target.
// of its own), and on touch its "…" sits beside the title as a 44px target. The frame, the pinned
// meta line and the picture's pieces are the one media card's (ui/MediaCard), shared with Plex.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering.
// by a pixel or two forever, which shows up as the whole page juddering. The grid stretches the
// card to its row on its own, which is what the pinned meta line needs.
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55",
)}
>
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
and would spill straight out of it. */}
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
}
/>
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
gap would leave the title barely half the width. The channel name is still below it. */}
{!spec.dense && (
<MediaCard
rootProps={{
"data-testid": "video-card",
"data-video-id": video.id,
...menuProps?.(video),
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className="cv-card"
dense={spec.dense}
dim={watched}
// The small card's image is in the SAME size class as a row's (its column bottoms out at
// 180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
// and would spill straight out of it.
picture={
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
media
/>
}
/>
}
// The small card drops the channel avatar: at its ~180px column the 36px avatar plus the gap
// would leave the title barely half the width. The channel name is still in the meta line.
leading={
!spec.dense && (
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<MoreButton
className="touch-only -mr-1 shrink-0"
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
</div>
)
}
title={title}
meta={
shelf ? (
channelLink
) : (
<>
{channelLink}
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{/* The small card keeps the relative time and moves the date into its tooltip: at a
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
the whole shelf's height (every card fills its slot). */}
{spec.dense ? (
<span
title={
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
}
>
{relativeTime(video.published_at)}
</span>
) : (
published
)}
</div>
</>
)
}
more={more || undefined}
moreLabel={t("card.menu.more")}
/>
);
}
+123 -7
View File
@@ -5,7 +5,7 @@
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "./ui/buttonStyles";
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
const movie = (status?: string): PlexCard => ({
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
// E28 S3h: the one media card — the year and the rating are the meta line under the title (pinned
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
// tooltip any more: it lives in the card's preview (below).
it("carries the year and rating in its meta line, the length on the picture", () => {
render(
<PlexPosterCard
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
card={{
...movie(),
year: 2023,
rating: 7.5,
duration_seconds: 6780,
summary: "A quiet synopsis.",
}}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1h 53m")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
it("omits the scrim when the card has no synopsis or rating", () => {
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.queryByText(/synopsis/i)).toBeNull();
it("draws no meta line when the card has neither a year nor a rating", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
);
expect(container.querySelector(".mt-auto")).toBeNull();
});
});
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
describe("PlexPosterCard preview", () => {
afterEach(() => vi.useRealTimers());
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
const root = () =>
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root());
act(() => vi.advanceTimersByTime(600));
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => vi.advanceTimersByTime(400));
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
it("does not open on keyboard focus", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => vi.advanceTimersByTime(2000));
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
describe("PlexPosterCard with a next episode", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
const onToggleWatched = vi.fn();
const onAdd = vi.fn();
const onInfo = vi.fn();
const { container } = render(
<PlexPosterCard
card={show}
next={next}
onOpen={vi.fn()}
onToggleWatched={onToggleWatched}
onAdd={onAdd}
onInfo={onInfo}
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
expect(onAdd).toHaveBeenCalledWith(next);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onInfo).toHaveBeenCalled();
});
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
const { container } = render(
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
);
expect(playGlyph(container)).toBeNull();
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
});
});
@@ -177,3 +260,36 @@ describe("a Plex card's caption and a drag", () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
// of the hover row under it (E28 S3h: at the large size they touched on a 232px slot).
describe("PlexPosterCard on a shelf", () => {
const titleOf = () => screen.getByText("M");
const play = (c: HTMLElement) =>
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
it("cuts the title to one line; a grid card keeps two", () => {
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
expect(titleOf().className).toContain("truncate");
unmount();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
expect(titleOf().className).toContain("line-clamp-2");
});
it("only a landscape SHELF card takes the smaller Play", () => {
const large = iconButtonClass({ variant: "media", size: "lg" });
const cases: [boolean, boolean, string][] = [
[true, true, iconButtonClass({ variant: "media", size: "md" })],
[true, false, large], // a poster on a shelf is tall enough
[false, true, large], // a landscape card in a grid is wide enough
];
for (const [shelf, wide, want] of cases) {
const { container, unmount } = render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
);
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
unmount();
}
});
});
+351 -305
View File
@@ -1,17 +1,29 @@
import { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react";
import { type CSSProperties, type ReactNode, useRef } from "react";
import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import clsx from "clsx";
import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { plexAddLabelKey } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { HOVER_YIELD } from "./ui/reveal";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
import { MediaPreview } from "./ui/MediaPreview";
import { useMediaPreview } from "./ui/useMediaPreview";
import { SURFACE } from "./ui/surfaces";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
@@ -42,7 +54,7 @@ function WatchedIcon({ watched }: { watched: boolean }) {
);
}
/** The hover row on a card's picture (top-right). A sibling of the picture's own button, never
/** The hover row on a card's picture (bottom-right, as on the YouTube card). A sibling of the picture's own button, never
* inside it, so no control nests in another. */
function HoverBar({
watched,
@@ -50,6 +62,7 @@ function HoverBar({
onToggleWatched,
addLabel,
onAdd,
onInfo,
more: m,
}: {
watched: boolean;
@@ -57,12 +70,14 @@ function HoverBar({
onToggleWatched?: () => void;
addLabel: string;
onAdd?: () => void;
/** The item's own page, when the click does something else (a show on Home plays its next episode). */
onInfo?: () => void;
more: More;
}) {
const { t } = useTranslation();
if (!onToggleWatched && !onAdd && !m) return null;
if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
return (
<div className={`${HOVER_REVEAL} absolute right-1.5 top-1.5 z-base flex gap-1`}>
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
{onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} />
@@ -73,6 +88,11 @@ function HoverBar({
<ListPlus />
</IconButton>
)}
{onInfo && (
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
<Info />
</IconButton>
)}
{m && (
<MoreButton
variant="media"
@@ -85,25 +105,52 @@ function HoverBar({
);
}
/** The caption under a card's picture, with the touch "…" at its end (the hover row's twin). */
function Caption({
/** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
* toggles and its "…", acting on a movie or an episode. */
export function PlexHoverBar({
card,
onToggleWatched,
onAdd,
moreProps,
moreOpen = false,
}: {
card: PlexCard;
onToggleWatched?: (c: PlexCard) => void;
onAdd?: (c: PlexCard) => void;
moreProps?: Record<string, unknown>;
moreOpen?: boolean;
}) {
const { t } = useTranslation();
const plays = card.type !== "show";
const watched = card.status === "watched";
return (
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={plays && onToggleWatched ? () => onToggleWatched(card) : undefined}
addLabel={t(plexAddLabelKey(card))}
onAdd={plays && onAdd ? () => onAdd(card) : undefined}
more={moreProps ? { props: moreProps, open: moreOpen } : undefined}
/>
);
}
/** A Plex card's title: it opens the card like its picture does, except for the click that ends
* selecting its words, or a press dragged past the slop (DESIGN "Text stays selectable"); a div's
* text selects on its own, so only the click needs the guard. */
function CardTitle({
onClick,
more: m,
className = "",
shelf = false,
children,
}: {
onClick?: () => void;
more: More;
className?: string;
/** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
shelf?: boolean;
children: ReactNode;
}) {
const { t } = useTranslation();
const pressedAt = useRef<{ x: number; y: number } | null>(null);
return (
<div
// The click that ends selecting the title's words, or a press dragged past the slop, does not
// open it (DESIGN "Text stays selectable"); a div's text selects on its own, so only the click
// needs the guard.
onClick={
onClick &&
((e) => {
@@ -114,22 +161,75 @@ function Caption({
onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY };
}}
className={`mt-1.5 flex items-start gap-1.5 rounded-lg px-2 py-1.5 ${onClick ? "cursor-pointer" : ""} ${className}`}
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }}
>
<div className="min-w-0 flex-1">{children}</div>
{m && (
<MoreButton
className="touch-only -mr-1 -my-0.5 shrink-0"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
className={clsx(
"min-h-6",
mediaTitleClass(true, shelf),
onClick && "cursor-pointer hover:text-accent",
)}
>
{children}
</div>
);
}
/** A Plex card's picture: the whole box is the card's open/play button (Enter opens it from the
* keyboard walk), with the hover row docked beside it rather than inside it. */
function PlexPicture({
aspect,
thumb,
label,
onOpen,
plays,
small = false,
pct = 0,
bar,
children,
}: {
aspect: "poster" | "wide";
thumb: string;
/** The button's name: the picture has no text of its own. */
label: string;
onOpen: () => void;
/** The click plays (a movie, an episode) — only then does the hover draw a Play. */
plays: boolean;
/** A shelf's short landscape picture: the smaller Play, clear of the hover row under it. */
small?: boolean;
pct?: number;
bar: ReactNode;
/** The badges printed on the picture. */
children?: ReactNode;
}) {
return (
<div className={mediaPictureBox(aspect)}>
<div
onClick={onOpen}
role="button"
tabIndex={0}
aria-label={label}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className={clsx(MEDIA_PICTURE, "cursor-pointer")}
>
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
{children}
<div className={MEDIA_HOVER_LAYER}>{plays && <MediaPlayGlyph small={small} />}</div>
<MediaProgress pct={pct} />
</div>
{bar}
</div>
);
}
const progressPct = (c: { position_seconds?: number; duration_seconds?: number | null }) =>
(c.position_seconds ?? 0) > 0 && c.duration_seconds
? Math.round(((c.position_seconds ?? 0) / c.duration_seconds) * 100)
: 0;
export function PlexPosterCard({
card,
onOpen,
@@ -139,6 +239,10 @@ export function PlexPosterCard({
menuButtonProps,
menuOpen = false,
navIndex,
wide = false,
shelf = false,
next,
onInfo,
}: {
card: PlexCard;
onOpen: () => void;
@@ -155,163 +259,149 @@ export function PlexPosterCard({
menuOpen?: boolean;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number;
/** Landscape, beside YouTube cards (Home's shelves): the item's wide art instead of its poster
* (the poster, cropped, when it has no art). An episode's picture is landscape already. */
wide?: boolean;
/** On a shelf (a horizontal strip): a one-line title (ui/mediaCardStyles). */
shelf?: boolean;
/** A SHOW whose click plays this episode (Home's shelves: a pinned or started show plays on). The
* picture then draws its Play, the watched toggle and the add act on the episode, the meta line
* names it, and an ⓘ (`onInfo`) opens the show. */
next?: PlexCard | null;
/** The show's page, beside a `next` episode. */
onInfo?: () => void;
}) {
const { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down
const preview = useMediaPreview();
const landscape = wide || card.type === "episode";
const ep = card.type === "show" ? (next ?? null) : null;
// movies/episodes play; a show opens its drill-down — unless it plays on (`next`)
const isPlayable = card.type !== "show" || !!ep;
// What the hover row acts on: the episode a show would play, else the card itself.
const acted = ep ?? card;
const watched = card.status === "watched";
const actedWatched = acted.status === "watched";
const runtime = formatRuntime(card.duration_seconds);
const pct =
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined;
const add = isPlayable && onAdd ? () => onAdd(card) : undefined;
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays.
const yieldTop = toggle || add || m ? HOVER_YIELD : "";
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
// A show has no length: its corner says how many seasons it has instead, so the corner alone tells
// a movie from a show (the film/TV glyph it replaces said the same thing a second time).
const corner =
card.type === "show"
? card.season_count != null && t("plex.seasons", { count: card.season_count })
: runtime;
const rating = card.rating != null && (
<span className="inline-flex items-center gap-0.5">
<Star className="w-3 h-3 fill-current text-rating" aria-hidden />
{card.rating.toFixed(1)}
</span>
);
return (
<div
className="group text-left"
{...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
>
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
<>
<MediaCard
dense
rootProps={preview.rootProps({
...menuProps?.(card),
...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
})}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
// a carousel turns it off (index.css `.media-rail`).
className="cv-poster"
picture={
<PlexPicture
aspect={landscape ? "wide" : "poster"}
thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
label={card.title}
onOpen={onOpen}
plays={isPlayable}
small={shelf && landscape}
pct={pct}
bar={
<HoverBar
watched={actedWatched}
watchedLabel={
epCode
? t(actedWatched ? "plex.markNextUnwatched" : "plex.markNextWatched", epCode)
: actedWatched
? t("plex.markUnwatched")
: t("plex.markWatched")
}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(acted))}
onAdd={add}
onInfo={ep ? onInfo : undefined}
more={m}
/>
}
}}
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
>
<img
src={card.thumb}
alt=""
loading="lazy"
decoding="async"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). z-menu keeps the
dot ABOVE the full-card hover-reveal overlay below (absolute inset-0, no
pointer-events-none): without it the overlay sits on top and swallows the cursor, so the
dot's native `title` tooltip never fired on hover. */}
{card.playable &&
(() => {
const cls = playableClass(card.playable, card.transcode_hw);
return (
<span
role="img"
aria-label={t(`plex.playable.${cls}`)}
className={`absolute top-1.5 left-1.5 z-menu w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[cls] ?? "bg-muted"}`}
title={t(`plex.playable.${cls}`)}
/>
);
})()}
{/* Rest-state watched badge (top-right). */}
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${yieldTop}`}
>
{t("plex.watched")}
</span>
)}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.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 ${yieldTop}`}
>
{t("plex.inProgress")}
</span>
)}
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
{card.type === "show" && card.season_count != null && (
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
{t("plex.seasons", { count: card.season_count })}
</span>
)}
<span
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
title={t(
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
)}
>
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
{watched && <WatchedBadge />}
{/* Aggregate in-progress badge for a partially-watched show. */}
{card.type === "show" && card.status === "in_progress" && <InProgressBadge />}
{corner && <CornerBadge yields={!!(toggle || add || m)}>{corner}</CornerBadge>}
</PlexPicture>
}
title={
<CardTitle onClick={onOpen} shelf={shelf}>
{card.title}
</CardTitle>
}
meta={
epCode ? (
<div className="truncate text-xs leading-6 text-muted">{t("plex.nextUp", epCode)}</div>
) : (
(!!card.year || card.rating != null) && (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
{!!card.year && <span>{card.year}</span>}
{!!card.year && rating && <span aria-hidden>·</span>}
{rating}
</div>
)
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
<MediaPreview
preview={preview}
title={card.title}
facts={
<span className="flex flex-wrap items-center gap-x-1.5">
{[card.year, corner].filter(Boolean).join(" · ")}
{rating && (!!card.year || !!corner) && <span aria-hidden>·</span>}
{rating}
</span>
}
summary={card.summary}
/>
</>
);
}
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with
the synopsis + rating (the info you'd otherwise click through for). */}
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div className="absolute inset-0 bg-black/25" />
<div className="absolute inset-x-0 top-[30%] flex justify-center">
<div
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-accent-fg shadow-xl transition-transform duration-200 group-hover:scale-100"
style={{
background:
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
boxShadow:
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
}}
>
<Play className="w-5 h-5 translate-x-px fill-current" />
</div>
</div>
{(card.summary || card.rating != null) && (
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
{card.title}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
{(card.year || runtime) && (
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
)}
{card.rating != null && (
<span className="inline-flex items-center gap-0.5 text-rating-media">
<Star className="w-3 h-3 fill-current" />
{card.rating.toFixed(1)}
</span>
)}
</div>
{card.summary && (
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
{card.summary}
</p>
)}
</div>
)}
</div>
/** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
function WatchedBadge() {
const { t } = useTranslation();
return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
}
{pct > 0 && (
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={toggle}
addLabel={t(plexAddLabelKey(card))}
onAdd={add}
more={m}
/>
</div>
<Caption onClick={onOpen} more={m}>
<div className="text-sm font-medium line-clamp-2 leading-tight min-h-[2lh] hover:text-accent">
{card.title}
</div>
<div className="text-xs text-muted">
{[card.year, formatRuntime(card.duration_seconds)].filter(Boolean).join(" · ")}
</div>
</Caption>
</div>
function InProgressBadge() {
const { t } = useTranslation();
return (
<span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
{t("plex.inProgress")}
</span>
);
}
/** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
* at rest, and on touch, it stays. */
function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
return (
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
{children}
</span>
);
}
@@ -339,72 +429,45 @@ export function SeasonCard({
const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen);
return (
<div
className="group text-left"
{...menuProps?.(se)}
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div className="relative">
<div
onClick={onOpen}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`}
>
<img
src={se.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<div className="flex flex-col items-center gap-1 text-white">
<Play className="w-9 h-9" fill="currentColor" />
<span className="text-xs font-medium">{t("plex.series.openSeason")}</span>
</div>
</div>
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
{t("plex.watched")}
</span>
)}
{/* In-progress lives at the top-LEFT (the hover row owns the top-right) and stays visible:
a season has no progress bar, so this badge is its sole in-progress cue. */}
{se.status === "in_progress" && (
<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>
)}
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
{t("plex.series.episodeCount", { count: se.episode_count })}
</span>
</div>
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
<MediaCard
dense
rootProps={{
...menuProps?.(se),
...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
}}
picture={
// A season opens its episode list: no Play on hover, since the click does not play.
<PlexPicture
aspect="poster"
thumb={se.thumb}
label={se.title}
onOpen={onOpen}
plays={false}
bar={
<HoverBar
watched={watched}
watchedLabel={
watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addSeason")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight">
<div className="line-clamp-2 hover:text-accent">{se.title}</div>
</Caption>
</div>
>
{watched && <WatchedBadge />}
{/* A season has no progress bar, so this badge is its sole in-progress cue. */}
{se.status === "in_progress" && <InProgressBadge />}
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
{t("plex.series.episodeCount", { count: se.episode_count })}
</CornerBadge>
</PlexPicture>
}
title={<CardTitle onClick={onOpen}>{se.title}</CardTitle>}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}
@@ -508,9 +571,10 @@ export function RelatedStrip({
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => (
<div key={r.id} className="w-37.5 shrink-0">
<div key={r.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
card={r}
shelf
navIndex={i}
onOpen={() => onOpen(r)}
menuProps={menuProps}
@@ -548,80 +612,62 @@ export function EpisodeCard({
}) {
const { t } = useTranslation();
const watched = ep.status === "watched";
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched;
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
const pct = watched ? 0 : progressPct(ep);
const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds);
return (
<div
className="group"
{...menuProps?.(ep)}
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}
>
<div className="relative">
<div
onClick={onPlay}
role="button"
tabIndex={0}
data-nav-open=""
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onPlay();
}
}}
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer"
<MediaCard
dense
rootProps={{
...menuProps?.(ep),
...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
}}
picture={
<PlexPicture
aspect="wide"
thumb={ep.thumb}
label={ep.title}
onOpen={onPlay}
plays
pct={pct}
bar={
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
}
>
<img
src={ep.thumb}
alt=""
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition grid place-items-center">
<Play className="w-9 h-9 text-white" fill="currentColor" />
</div>
{watched && (
<span
className={`pointer-events-none absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 ${
onToggleWatched || onAdd || m ? HOVER_YIELD : ""
}`}
>
<CheckCircle2 className="w-4 h-4 text-success" />
</span>
{watched && <WatchedBadge />}
{runtime && (
<CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
)}
{pct > 0 && (
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
</PlexPicture>
}
title={
<CardTitle onClick={onPlay}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)}
</div>
<HoverBar
watched={watched}
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
</div>
<Caption more={m}>
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.title}
</CardTitle>
}
meta={
withShowTitle &&
ep.show_title && (
<div className="truncate text-xs text-muted">
{ep.show_title}
{ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}`
: ""}
</div>
)}
<div className="text-sm font-medium leading-tight">
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>}
{ep.title}
</div>
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</Caption>
</div>
)
}
more={m}
moreLabel={t("card.menu.more")}
/>
);
}
+22 -18
View File
@@ -262,7 +262,7 @@ export function PlexPersonView({
</div>
</div>
{d.biography && (
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
<p className="measure mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
{d.biography}
</p>
)}
@@ -277,8 +277,9 @@ export function PlexPersonView({
keyboardNav
>
{d.known_for.map((c, i) => (
<div key={c.id} className="w-37.5 shrink-0">
<div key={c.id} className="grid w-37.5 shrink-0 grid-cols-1">
<PlexPosterCard
shelf
card={c}
navIndex={i}
onOpen={() => onOpenCard(c)}
@@ -470,9 +471,12 @@ export function PlexShowView({
}
>
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted">
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
text, spaced by the gap alone (a '·' between chips reads as noise). */}
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted">
{show.year != null && (
<Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={
onFilter
@@ -484,21 +488,19 @@ export function PlexShowView({
</Filterable>
)}
{show.content_rating && (
<span>
·{" "}
<Filterable
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
{show.content_rating}
</Filterable>
</span>
<Filterable
tag
onClick={
onFilter
? () => onFilter({ contentRatings: [show.content_rating!] })
: undefined
}
>
{show.content_rating}
</Filterable>
)}
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
<span>{t("plex.seasons", { count: show.season_count })}</span>
)}
{(() => {
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
@@ -518,7 +520,7 @@ export function PlexShowView({
className={`inline-flex items-center gap-1 ${onFilter ? "min-h-6 cursor-pointer hover:text-accent touch-target" : "cursor-default"}`}
>
{/* the rating gold — its own token, not --warn: a score is not a warning */}
· <Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
<Star className="w-3.5 h-3.5 text-rating" fill="currentColor" />
{score.toFixed(1)}
</Pressable>
);
@@ -538,7 +540,9 @@ export function PlexShowView({
</div>
)}
{show.summary && (
<p className="text-sm text-muted mt-3 leading-relaxed line-clamp-5">{show.summary}</p>
<p className="measure mt-3 line-clamp-5 text-sm leading-relaxed text-muted">
{show.summary}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{show.resume && (
+4 -1
View File
@@ -132,7 +132,10 @@ export function Carousel({
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
<div
ref={setScroller}
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
className={clsx(
"media-rail no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1",
gap,
)}
style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group"
aria-label={label}
+100
View File
@@ -0,0 +1,100 @@
import type { HTMLAttributes, ReactNode } from "react";
import clsx from "clsx";
import { Play } from "lucide-react";
import { iconButtonClass } from "./buttonStyles";
import { MoreButton } from "./MoreButton";
import { SURFACE } from "./surfaces";
// The ONE media card (docs/DESIGN.md "Media card", E28 S3h): a YouTube video, a Plex movie, show,
// season or episode — in the feed, the Plex library, Home's shelves and the media pages' strips — is
// the same object: one glass frame holding the picture, then the title, then a meta line PINNED to the
// frame's bottom edge. Cards in a grid row stretch to the row's height, so pinning the meta puts every
// card's meta on one line whatever its title's length; a one-line title leaves room between the title
// and the meta, never an empty line under it (the owner's pick, option C, 2026-09-26). The length is
// a badge ON the picture, as YouTube draws it.
//
// The callers keep what differs (a YouTube picture is a link, a Plex picture is the card's play
// button) and build it from the pieces here and in ./mediaCardStyles, so the look cannot drift
// apart again.
/** The Play drawn on a picture whose whole box is the play button: the `media` IconButton's look
* (so it matches the YouTube card's real one), but only a picture of it — the box around it is the
* control, and a button inside a button is not allowed. Only where a click PLAYS: a show or a season
* opens a page, and a Play there would be a promise the click does not keep. */
export function MediaPlayGlyph({ small = false }: { small?: boolean }) {
return (
<span aria-hidden className={iconButtonClass({ variant: "media", size: small ? "md" : "lg" })}>
<Play className="translate-x-px fill-current" />
</span>
);
}
/** How far into it the viewer is: a bar along the picture's bottom edge. */
export function MediaProgress({ pct }: { pct: number }) {
if (pct <= 0) return null;
return (
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${Math.min(100, pct)}%` }} />
</div>
);
}
export function MediaCard({
rootProps,
dense = false,
dim = false,
picture,
leading,
title,
meta,
more,
moreLabel = "",
className,
}: {
/** The card's own attributes: its right-click menu, its keyboard-walk position, a test id. */
rootProps?: HTMLAttributes<HTMLDivElement> & Record<string, unknown>;
/** A narrow column (a small card, a rail, a poster grid): tighter padding and a smaller title. */
dense?: boolean;
/** Fades the whole card (a watched video in the feed). */
dim?: boolean;
picture: ReactNode;
/** Beside the text, before the title (the YouTube channel's avatar). */
leading?: ReactNode;
title: ReactNode;
/** The line pinned to the frame's bottom edge; omit for a card with none. */
meta?: ReactNode;
/** The card's "…": on touch it sits beside the title as a 44px target (the hover row's twin). */
more?: { props: Record<string, unknown>; open: boolean };
/** The "…"'s name (the caller's words: this platform file speaks no feature namespace). */
moreLabel?: string;
className?: string;
}) {
return (
<div
{...rootProps}
className={clsx(
`group ${SURFACE.card} glass-hover flex flex-col text-left transition-all duration-150`,
dense ? "p-2" : "p-2.5",
dim && "opacity-55",
className,
)}
>
{picture}
<div className={clsx("flex flex-1 gap-3 px-0.5 pb-0.5", dense ? "mt-2" : "mt-2.5")}>
{leading}
<div className="flex min-w-0 flex-1 flex-col">
{title}
{meta && <div className="mt-auto pt-1">{meta}</div>}
</div>
{more && (
<MoreButton
className="touch-only -mr-1 shrink-0"
label={moreLabel}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { PopoverSurface } from "./AnchoredPopover";
import type { useMediaPreview } from "./useMediaPreview";
import { SURFACE } from "./surfaces";
// A media card's preview (docs/DESIGN.md "Media card", E28 S3h — the owner's pick C, 2026-09-26):
// rest the mouse on a card for a moment and a panel beside it shows what the card has no room for —
// the whole title, the facts, the synopsis in lines short enough to read. It replaced the synopsis as
// the picture's native tooltip, which fired anywhere on the art, ran one long line, and shared its
// mechanism with the cut title's own tooltip.
//
// Mouse only (`pointerOnly`): a keyboard walk down a grid would pop a panel at every stop, and on
// touch a tap is the card's play. The same facts are one row away there ("…" → Media info). Passive
// (`passthrough`), so the panel lying over the next card never eats the click aimed at it; any press
// on the card closes it, so it never sits over a menu the card just opened.
export function MediaPreview({
preview,
title,
facts,
summary,
}: {
preview: ReturnType<typeof useMediaPreview>;
title: string;
/** One line under the title: year, length, score. */
facts?: ReactNode;
summary?: string | null;
}) {
return (
<PopoverSurface
popover={preview.popover}
passthrough
// 18rem at text-xs: ~50 characters a line, inside the 45–75 a reader follows without losing
// the next line's start.
className={`${SURFACE.popover} w-72 px-3.5 py-3 text-left normal-case tracking-normal animate-[fadeIn_0.12s_ease]`}
>
<p className="text-sm font-semibold leading-snug text-fg">{title}</p>
{facts && <div className="mt-0.5 text-xs text-muted">{facts}</div>}
{summary && <p className="mt-2 line-clamp-8 text-xs leading-relaxed text-fg">{summary}</p>}
</PopoverSurface>
);
}
+1 -1
View File
@@ -43,7 +43,7 @@ domain-free.
B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a
link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -58,6 +58,10 @@ export interface AnchoredPopoverOptions {
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
* hair before the click handler toggles it shut again. */
hoverAlso?: boolean;
/** Hover mode only: a MOUSE affordance and nothing else — no focus-open, no touch. A media card's
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
* and a tap is the card's play. */
pointerOnly?: boolean;
/** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then
* lands the roving focus on it instead of the first item — the behaviour a "pick one of these
* modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -157,13 +161,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
move: false,
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
mouseOnly: !isHover,
mouseOnly: !isHover || !!opts.pointerOnly,
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
// menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
handleClose: safePolygon(),
});
const focus = useFocus(context, { enabled: isHover });
const focus = useFocus(context, { enabled: isHover && !opts.pointerOnly });
// Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
@@ -0,0 +1,40 @@
import clsx from "clsx";
import { HOVER_REVEAL } from "./reveal";
// The one media card's classes (ui/MediaCard): the pieces a caller builds its picture from, kept in
// a module of their own so the component file exports only components (fast refresh).
/** The picture's own box: rounded inside the frame, edged, lifting its shadow on hover. */
export const MEDIA_PICTURE =
"relative block h-full w-full overflow-hidden rounded-xl border border-border bg-surface shadow-md transition-shadow group-hover:shadow-2xl";
/** The picture's image: covers the box and zooms a little on hover. */
export const MEDIA_IMG =
"h-full w-full object-cover transition-transform duration-200 group-hover:scale-105";
/** A fact printed on the picture (a length, a season count, "Watched"): dark, legible on any art. */
export const MEDIA_BADGE =
"pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums";
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row). */
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";
/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */
export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`;
/** The title's type, one size per density. On a SHELF (any horizontal strip: Home, the Plex rails,
* a collection strip) it is one line, cut with an ellipsis, over one meta line — every card on the
* strip is then exactly two lines of text under its picture (the owner, 2026-09-26: the pinned meta
* still left a hole in the cards with less to say). A grid keeps two lines and its full meta. */
export const mediaTitleClass = (dense: boolean, shelf = false) =>
clsx(
"font-medium leading-snug",
shelf ? "block truncate" : "line-clamp-2",
(dense || shelf) && "text-sm",
);
/** The box that holds the picture at its ratio. `min-h-0`: an aspect-ratio box otherwise grows to
* its content's height, so a square channel avatar made a 16:9 slot square (measured 182×182). */
export const mediaPictureBox = (aspect: "poster" | "wide") =>
clsx("relative min-h-0", aspect === "poster" ? "aspect-2/3" : "aspect-video");
@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// A card skips its off-screen rendering (`cv-card` / `cv-poster`: content-visibility auto with a
// reserved height), but not inside a carousel. A rail stretches every card to its tallest, and an
// off-screen card's reserve WAS the tallest — Home's shelves measured 338px around 170–216px cards
// (E28 S3h). The pair lives in two files and neither can be seen in jsdom, so it is held here.
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
describe("a carousel turns the cards' content-visibility off", () => {
it("the carousel's track is the .media-rail", () => {
expect(read("./Carousel.tsx")).toMatch(/["`]media-rail /);
});
it("index.css makes both card kinds visible inside it", () => {
expect(read("../../index.css")).toMatch(
/\.media-rail \.cv-card,\s*\.media-rail \.cv-poster\s*\{\s*content-visibility: visible;/,
);
});
});
@@ -16,7 +16,10 @@ const files = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-la
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"Home.tsx": [1, "the hero's picture (named by aria-label; its title sits outside)"],
"Home.tsx": [
2,
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
],
"LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
@@ -0,0 +1,39 @@
import { useState, type HTMLAttributes } from "react";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
// The hover wiring of a media card's preview (./MediaPreview has the why).
/** How long the pointer must rest on a card: long enough that a sweep across a grid opens nothing. */
const MEDIA_PREVIEW_DELAY = 900;
export function useMediaPreview(enabled = true) {
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open: open && enabled,
onOpenChange: setOpen,
mode: "hover",
placement: "right-start",
offset: 8,
hoverDelay: { open: MEDIA_PREVIEW_DELAY, close: 0 },
pointerOnly: true,
});
return {
popover: pop,
/** The card root's props: the anchor, the hover wiring, and a close on any press — merged with
* the root's own (its menu's right-click and long-press). */
rootProps: (own: HTMLAttributes<HTMLDivElement> & Record<string, unknown> = {}) => ({
...(pop.getReferenceProps({
...own,
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => {
setOpen(false);
(own.onPointerDown as ((e: React.PointerEvent<HTMLDivElement>) => void) | undefined)?.(e);
},
onContextMenu: (e: React.MouseEvent<HTMLDivElement>) => {
setOpen(false);
(own.onContextMenu as ((e: React.MouseEvent<HTMLDivElement>) => void) | undefined)?.(e);
},
}) as HTMLAttributes<HTMLDivElement> & Record<string, unknown>),
ref: pop.refs.setReference,
}),
};
}
+12
View File
@@ -3,6 +3,7 @@ import { SCHEMES, type Scheme } from "../lib/theme";
import { BatchA } from "./batchA";
import { BatchB } from "./batchB";
import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces";
@@ -82,6 +83,9 @@ export default function DesignLab() {
<a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success state-layer">
C ✓
</a>
<a href="#decided-s3h" className="rounded-md px-1.5 py-0.5 text-success state-layer">
S3h ✓
</a>
</nav>
</div>
@@ -114,6 +118,14 @@ export default function DesignLab() {
</p>
</header>
<BatchC />
<header id="decided-s3h" className="mt-6 scroll-mt-20">
<h2 className="text-xl font-bold">Decided · S3h, the media card</h2>
<p className="text-sm text-muted">
Hover a card: its Play and its hover row. The preview, the hero and the info page live
in the app.
</p>
</header>
<BatchD />
</main>
</div>
);
+143
View File
@@ -0,0 +1,143 @@
import clsx from "clsx";
import { Bookmark, Check, ListPlus, MoreHorizontal, Star } from "lucide-react";
import { Reference, Sample } from "./labKit";
import { MediaCard, MediaPlayGlyph } from "../components/ui/MediaCard";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE,
mediaPictureBox,
mediaTitleClass,
} from "../components/ui/mediaCardStyles";
import { iconButtonClass } from "../components/ui/buttonStyles";
import { HOVER_YIELD } from "../components/ui/reveal";
// E28 S3h — the one media card, DECIDED (owner, 2026-09-26/27: anatomy C, shelf density, the preview
// C, the Home hero A, list row B, info layout B). Drawn with the real ui/MediaCard and its classes;
// hover a card to see its Play and its hover row. The preview panel, the hero and the info page are
// live in the app (Plex, Home), not copied here.
const BAR_BUTTON = iconButtonClass({ variant: "media", size: "sm" });
function HoverRow({ save = false }: { save?: boolean }) {
return (
<div className={MEDIA_BAR}>
<span className="flex gap-1">
{[Check, ...(save ? [Bookmark] : []), ListPlus, MoreHorizontal].map((Icon, i) => (
<span key={i} className={BAR_BUTTON}>
<Icon />
</span>
))}
</span>
</div>
);
}
function Picture({
aspect,
length,
small = false,
save = false,
}: {
aspect: "poster" | "wide";
length: string;
small?: boolean;
save?: boolean;
}) {
return (
<div className={mediaPictureBox(aspect)}>
<div className={clsx(MEDIA_PICTURE, "bg-linear-to-br from-accent/45 to-card")}>
<span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", HOVER_YIELD)}>{length}</span>
<div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph small={small} />
</div>
</div>
<HoverRow save={save} />
</div>
);
}
const score = (
<span className="inline-flex items-center gap-0.5">
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
7.8
</span>
);
const plexMeta = (
<div className="flex items-center gap-1.5 text-xs leading-6 text-muted">
<span>2003</span>
<span aria-hidden>·</span>
{score}
</div>
);
export function BatchD() {
return (
<div className="flex flex-col gap-4">
<Reference
id="s3h-card"
code="S3h"
title="The media card"
rule="One frame: the picture, the title, the meta pinned to the bottom edge. The length is a badge on the picture; the Play shows only where the click plays; the hover row docks bottom-right and the length steps aside. A grid card: two title lines and the full meta. On a shelf: one title line over one meta line, a 232px slot, and a landscape picture takes the smaller Play."
>
<div className="flex flex-wrap items-stretch gap-4">
<Sample label="Grid · a film (poster)">
<div className="grid w-44 grid-cols-1">
<MediaCard
dense
picture={<Picture aspect="poster" length="2h 34m" />}
title={
<div className={clsx("min-h-6", mediaTitleClass(true))}>
The Last Samurai, a title long enough to wrap
</div>
}
meta={plexMeta}
/>
</div>
</Sample>
<Sample label="Grid · a video">
<div className="grid w-72 grid-cols-1">
<MediaCard
picture={<Picture aspect="wide" length="18:04" save />}
title={<div className={mediaTitleClass(false)}>A video title</div>}
meta={
<>
<div className="text-sm text-muted">A channel</div>
<div className="mt-0.5 text-xs text-muted">12K views · 3 days ago</div>
</>
}
/>
</div>
</Sample>
<Sample label="Shelf · 232px, landscape, the smaller Play">
<div className="grid w-58 grid-cols-1">
<MediaCard
dense
picture={<Picture aspect="wide" length="2h 34m" small />}
title={
<div className={clsx("min-h-6", mediaTitleClass(true, true))}>
The Last Samurai, cut to one line on a shelf
</div>
}
meta={plexMeta}
/>
</div>
</Sample>
</div>
</Reference>
<Reference
id="s3h-measure"
code="S3h"
title="The reading measure"
rule="Prose (a synopsis, a biography, a channel's description) runs at most ~65 characters a line: the `measure` utility, wherever its container can be wider."
>
<p className="measure text-sm leading-relaxed text-muted">
Bourne is brought out of hiding once again by a reporter who is trying to unveil an
operation, an upgrade to a programme, in a series of newspaper columns. Information from
the reporter stirs a new set of memories, and Bourne must finally uncover his dark past.
</p>
</Reference>
</div>
);
}
+2 -1
View File
@@ -39,7 +39,8 @@
"moveRight": "Right",
"moveLast": "To the end",
"moveFailed": "Could not reorder the shelf.",
"moveSaving": "Saving the new order…"
"moveSaving": "Saving the new order…",
"openPlaylist": "Open the playlist"
},
"pin": {
"menu": "Comfort shelf",
+5 -2
View File
@@ -125,6 +125,9 @@
"menu": "Media",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"markNextWatched": "Mark S{{season}}·E{{episode}} watched",
"markNextUnwatched": "Mark S{{season}}·E{{episode}} unwatched",
"nextUp": "Next: S{{season}}·E{{episode}}",
"watchedFailed": "Couldn't update the watched state — please try again.",
"seasons": "{{count}} seasons",
"unified": {
@@ -243,7 +246,7 @@
"title": "Media info",
"customize": "Customize view",
"prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew",
"prefCast": "Cast",
"prefRelated": "Related shows",
"prefFacts": "Details",
"prefKeywords": "Keywords",
@@ -258,7 +261,7 @@
},
"openImdb": "Open on IMDb",
"director": "Director",
"cast": "Cast & crew",
"cast": "Cast",
"markWatched": "Mark watched",
"markUnwatched": "Mark unwatched",
"clearResume": "Clear resume position",
+2 -1
View File
@@ -39,7 +39,8 @@
"moveRight": "Jobbra",
"moveLast": "A polc végére",
"moveFailed": "Nem sikerült átrendezni a polcot.",
"moveSaving": "Az új sorrend mentése…"
"moveSaving": "Az új sorrend mentése…",
"openPlaylist": "Lejátszási lista megnyitása"
},
"pin": {
"menu": "Komfort polc",
+4 -1
View File
@@ -125,6 +125,9 @@
"menu": "Média",
"markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl",
"markNextWatched": "S{{season}}·E{{episode}} megnézettnek jelöl",
"markNextUnwatched": "S{{season}}·E{{episode}} nem-nézettnek jelöl",
"nextUp": "Következik: S{{season}}·E{{episode}}",
"watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
"seasons": "{{count}} évad",
"unified": {
@@ -258,7 +261,7 @@
},
"openImdb": "Megnyitás az IMDb-n",
"director": "Rendező",
"cast": "Szereplők & stáb",
"cast": "Szereplők",
"markWatched": "Megjelölés megnézettként",
"markUnwatched": "Megnézett visszavonása",
"clearResume": "Folytatási pozíció törlése",
+20
View File
@@ -142,6 +142,14 @@
border-color: var(--edge-strong);
}
/* The reading measure for prose (a synopsis, a biography, a channel's description): ~65 characters
a line, inside the 45–75 a reader follows back to the next line's start. A full-width page ran
one to 110 (owner, E28 S3h: "shorter lines are easier to follow"). Only where the container can
be wider than that; a narrow panel or popover is its own measure. */
@utility measure {
max-width: 65ch;
}
/* B9: the header + fade edge — a crisp band with a 1rem gradient of the colour it sits on, so content
dissolves as it slides under (the reference is DataTable's sticky header). The host is positioned. */
/* A STICKY header's fade (a table head, the server log's toolbar), inside the scroller it sticks in.
@@ -865,6 +873,18 @@ html[data-perf="1"] .overlay-backdrop {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
/* A Plex poster card in a grid: the same skip, at a poster's height. */
.cv-poster {
content-visibility: auto;
contain-intrinsic-size: auto 300px;
}
/* ...but not in a carousel. A rail stretches every card to its tallest (the pinned meta line,
ui/MediaCard), and an off-screen card's reserved box WAS the tallest: Home's shelves measured 338px
where the real cards were 170–216. A rail holds a handful of cards; there is nothing to skip. */
.media-rail .cv-card,
.media-rail .cv-poster {
content-visibility: visible;
}
.cv-row {
content-visibility: auto;
contain-intrinsic-size: auto 96px;