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 ( return (
<> <>
{ch.description ? ( {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)} {linkify(ch.description)}
</p> </p>
) : ( ) : (
+56 -1
View File
@@ -11,7 +11,7 @@
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. // a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { StrictMode } from "react"; import { StrictMode } from "react";
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; 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]); 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}`, 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() }) })); vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
import Home from "./Home"; import Home from "./Home";
@@ -150,6 +156,7 @@ const item = (label: string) => {
}; };
beforeEach(() => { beforeEach(() => {
openChannel.mockReset();
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one. // Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
window.history.replaceState(null, ""); window.history.replaceState(null, "");
discover.mockReset().mockResolvedValue(payload()); discover.mockReset().mockResolvedValue(payload());
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
fireEvent.contextMenu(await screen.findByText("Comfort B")); fireEvent.contextMenu(await screen.findByText("Comfort B"));
expect(labels()).toEqual([ expect(labels()).toEqual([
"home.hero.play", "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.moveFirst",
"home.shelf.moveLeft", "home.shelf.moveLeft",
"home.shelf.moveRight", "home.shelf.moveRight",
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
expect(item("home.pin.add").disabled).toBe(true); 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, ChevronRight,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
ArrowUpRight,
PinOff, PinOff,
Play, Play,
RotateCcw, RotateCcw,
Star,
} from "lucide-react"; } from "lucide-react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { import {
@@ -26,10 +28,9 @@ import {
} from "../lib/api"; } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors"; import { notifyActionError } from "../lib/actionErrors";
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent"; 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 { toggleWatchState } from "../lib/plexWatch";
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin"; import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { type ActionMenuItem } from "./ui/ActionMenu"; import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
@@ -37,15 +38,29 @@ import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu"; import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard"; import VideoCard, { VideoHoverActions } from "./VideoCard";
import { useVideoActions } from "./useVideoActions"; import { useVideoActions } from "./useVideoActions";
import { usePlexCardActions } from "./usePlexCardActions"; import { usePlexCardActions } from "./usePlexCardActions";
import { PlexPosterCard } from "./plexCards"; import { PlexHoverBar, PlexPosterCard } from "./plexCards";
import { usePlaylistsActions } from "./playlistsContext";
import { useNavigationActions } from "./navigationContext"; import { useNavigationActions } from "./navigationContext";
import { usePlexActions } from "./plexContext"; import { usePlexActions } from "./plexContext";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; 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?". // 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 // SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
// silently kept offering four. // silently kept offering four.
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at // A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would // object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value, // 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. // 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 // 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 // (`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], ["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({ function Rail({
title, title,
ariaLabel, ariaLabel,
@@ -118,83 +279,16 @@ function Rail({
/** A reorder is being written: the move rows wait for it (one write at a time). */ /** A reorder is being written: the move rows wait for it (one write at a time). */
moveSaving?: boolean; moveSaving?: boolean;
}) { }) {
const { t } = useTranslation(); const { menu, items, panels, plexAdd } = useHomeCardMenu({
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu cards,
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights onPlay,
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free. onInfo,
const menu = useContextMenu<DiscoverCard>(); onState,
const pin = useComfortPin(menu.open); onToggleSave,
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its onToggleWatched,
// Play row (it resolves `next`) and its shelf rows around it. onMove,
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open); moveSaving,
// 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)),
];
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the // 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. // card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
@@ -221,13 +315,19 @@ function Rail({
{cards.map((c, i) => ( {cards.map((c, i) => (
<div <div
key={`${c.source}:${c.kind}:${c.id}`} key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"} // Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }} // 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 ? ( {c.video ? (
<VideoCard <VideoCard
video={c.video} video={c.video}
view="cardsSmall" view="cardsSmall"
shelf
width={YT_SLOT} width={YT_SLOT}
navIndex={i} navIndex={i}
onState={onState} onState={onState}
@@ -240,23 +340,33 @@ function Rail({
) : c.plex ? ( ) : c.plex ? (
<PlexPosterCard <PlexPosterCard
card={c.plex} card={c.plex}
wide
shelf
navIndex={i} navIndex={i}
onOpen={() => onPlay(c)} onOpen={() => onPlay(c)}
onToggleWatched={onToggleWatched} onToggleWatched={onToggleWatched}
onAdd={plexActions.add} onAdd={plexAdd}
next={c.next?.plex}
onInfo={() => onInfo(c)}
menuProps={menuFor[i]} menuProps={menuFor[i]}
menuButtonProps={buttonFor[i]} menuButtonProps={buttonFor[i]}
menuOpen={menu.open && menu.target === c} 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> </div>
))} ))}
</Carousel> </Carousel>
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} /> <ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
{videoActions.panel} {panels}
{plexActions.panel}
</section> </section>
); );
} }
@@ -268,45 +378,90 @@ function ShelfTile({
card, card,
onPlay, onPlay,
menuProps, menuProps,
menuButtonProps,
menuOpen = false,
navIndex, navIndex,
}: { }: {
card: DiscoverCard; card: DiscoverCard;
onPlay: (card: DiscoverCard) => void; 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; navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const openPage = useOpenShelfPage();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count; const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
const countLabel = const countLabel =
card.kind === "channel" card.kind === "channel"
? t("home.shelf.unwatchedCount", { count: count ?? 0 }) ? t("home.shelf.unwatchedCount", { count: count ?? 0 })
: t("home.shelf.itemCount", { 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 ( return (
<Pressable <MediaCard
selectable dense
onClick={() => card.next && onPlay(card)} rootProps={{
{...menuProps?.(card)} ...menuProps,
{...(navIndex !== undefined && navItemAttrs(navIndex))} ...(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 className={!card.next ? "opacity-60" : undefined}
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower picture={
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`, // The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it // on the stretched frame itself that would be the whole card's height.
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards), <div className={mediaPictureBox("wide")}>
// alive to all three input paths. <Pressable
aria-disabled={!card.next} onClick={() => card.next && onPlay(card)}
className={`${SURFACE.card} glass-hover block w-full overflow-hidden transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent`} aria-label={card.title}
> // `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
<span className="block aspect-video bg-card overflow-hidden"> // playable, but it IS the tile you most want to take off the shelf, and the only way to
{card.thumb ? ( // do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" /> // narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
) : null} // `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
</span> // and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
<span className="block p-2"> // handler guards), alive to all three input paths.
<span className="block text-xs font-medium truncate">{card.title}</span> aria-disabled={!card.next}
<span className="block text-2xs text-muted">{countLabel}</span> data-nav-open=""
</span> className={MEDIA_PICTURE}
</Pressable> >
{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, tier,
card, card,
onPlay, onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
onReroll, onReroll,
rerolling, rerolling,
}: { }: {
tier: HeroTier; tier: HeroTier;
card: DiscoverCard; 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; onReroll: () => void;
rerolling: boolean; rerolling: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just // The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
// answered with — so it answers the same right-click every card in the app does. // the same whole action list on its "…", right-click and long-press — it was a pin row alone.
const pinMenu = useContextMenu<DiscoverCard>(); const { menu, items, panels, plexAdd } = useHomeCardMenu({
cards: [card],
onPlay,
onInfo,
onState,
onToggleSave,
onToggleWatched,
});
const started = card.position_seconds > 0; const started = card.position_seconds > 0;
const left = card.remaining_seconds; const left = card.remaining_seconds;
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where // 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.comfort")
: t("home.hero.surprise"); : t("home.hero.surprise");
return ( // One line of facts under the title: a film's year, length and score; a video's views and age.
<section // The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
{...pinMenu.triggerProps(card)} // hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
className={`${SURFACE.panel} mb-6 p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]`} // its hero as well as a film does (the owner's pick, 2026-09-26).
> const plex = card.plex;
<Pressable const video = card.video;
onClick={() => onPlay(card)} const facts = plex
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${ ? [plex.year, formatRuntime(card.duration_seconds)]
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video" : video
}`} ? // The channel is the subtitle already.
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`} [
> video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null} relativeTime(video.published_at),
{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)} const factLine = facts.filter(Boolean).join(" · ");
</span> // A feed video carries no description, so only a film or an episode has a summary line.
)} const summary = plex?.summary;
{started && card.duration_seconds ? ( // A film's subtitle is its year, which the fact line already says.
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60"> const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
<span
className="block h-full bg-accent"
style={{
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
}}
/>
</span>
) : null}
</Pressable>
<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> <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> <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>} {subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3"> {(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)}> <Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
{started ? t("home.hero.continue") : t("home.hero.play")} {started ? t("home.hero.continue") : t("home.hero.play")}
</Button> </Button>
@@ -386,16 +626,18 @@ function Hero({
> >
{t("home.hero.reroll")} {t("home.hero.reroll")}
</Button> </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>
</div> </div>
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself <ContextMenu menu={menu} ariaLabel={card.title} items={items} />
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin. {panels}
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}
/>
</section> </section>
); );
} }
@@ -776,6 +1018,10 @@ export default function Home() {
tier={hero.tier} tier={hero.tier}
card={hero.card} card={hero.card}
onPlay={onPlay} onPlay={onPlay}
onInfo={onInfo}
onState={onState}
onToggleSave={onToggleSave}
onToggleWatched={onToggleWatched}
onReroll={() => reroll()} onReroll={() => reroll()}
rerolling={rerolling} rerolling={rerolling}
/> />
@@ -326,7 +326,7 @@ export default function PlexCollectionView({
{d.summary && ( {d.summary && (
<div className="mt-3"> <div className="mt-3">
<p <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} {d.summary}
</p> </p>
+2 -1
View File
@@ -70,8 +70,9 @@ function CardRail({
keyboardNav keyboardNav
> >
{cards.map((c, i) => ( {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 <PlexPosterCard
shelf
card={c} card={c}
navIndex={i} navIndex={i}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
+58 -1
View File
@@ -5,7 +5,7 @@
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is // 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 // 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). // 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 { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -156,3 +156,60 @@ describe("PlexInfo enriched detail (page)", () => {
expect(screen.getByText("IMDb")).toBeTruthy(); 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 { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton"; import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings"; 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, // 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 // 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, // 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. // 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({ function FactsPanel({
detail, detail,
onFilter, onFilter,
@@ -175,9 +178,9 @@ function FactsPanel({
label: t("plex.info.facts.release"), label: t("plex.info.facts.release"),
node: ( node: (
<Filterable <Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year }) : undefined} title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined} onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
className="hover:text-accent"
> >
{year} {year}
</Filterable> </Filterable>
@@ -195,10 +198,7 @@ function FactsPanel({
key: "cert", key: "cert",
label: t("plex.info.facts.cert"), label: t("plex.info.facts.cert"),
node: ( node: (
<Filterable <Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}
className="hover:text-accent"
>
{cert} {cert}
</Filterable> </Filterable>
), ),
@@ -212,17 +212,15 @@ function FactsPanel({
key: "studios", key: "studios",
label: t("plex.info.facts.studios"), label: t("plex.info.facts.studios"),
node: ( node: (
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5"> <span className="flex flex-wrap gap-1.5">
{studios.map((s, i) => ( {studios.map((s) => (
<span key={s}> <Filterable
<Filterable key={s}
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined} tag
className="hover:text-accent" onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
> >
{s} {s}
</Filterable> </Filterable>
{i < studios.length - 1 ? "," : ""}
</span>
))} ))}
</span> </span>
), ),
@@ -237,7 +235,7 @@ function FactsPanel({
return ( return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}> <div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2> <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) => ( {rows.map((r) => (
<div key={r.key} className="contents"> <div key={r.key} className="contents">
<dt className="text-muted">{r.label}</dt> <dt className="text-muted">{r.label}</dt>
@@ -291,9 +289,8 @@ function CrewPanel({
return ( return (
<div className={`${SURFACE.panel} p-4 sm:p-5`}> <div className={`${SURFACE.panel} p-4 sm:p-5`}>
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2> <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, {/* In the main column (layout B): as many name columns as its width holds. */}
so a fixed small column count keeps names from over-spreading. */} <div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{crew.map((c, i) => ( {crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0"> <div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? ( {onOpenPerson ? (
@@ -536,7 +533,8 @@ export default function PlexInfo({
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
{detail.year != null && ( {detail.year != null && (
<Filterable <Filterable
className={mutedCls} tag
onDark={overlay}
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined} title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
onClick={ onClick={
onFilter onFilter
@@ -549,7 +547,8 @@ export default function PlexInfo({
)} )}
{detail.content_rating && ( {detail.content_rating && (
<Filterable <Filterable
className={mutedCls} tag
onDark={overlay}
onClick={ onClick={
onFilter onFilter
? () => onFilter({ contentRatings: [detail.content_rating!] }) ? () => onFilter({ contentRatings: [detail.content_rating!] })
@@ -564,7 +563,8 @@ export default function PlexInfo({
) : null} ) : null}
{detail.studio && ( {detail.studio && (
<Filterable <Filterable
className={mutedCls} tag
onDark={overlay}
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined} onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
> >
{detail.studio} {detail.studio}
@@ -608,25 +608,24 @@ export default function PlexInfo({
)} )}
{detail.directors.length > 0 && ( {detail.directors.length > 0 && (
<p className={`mt-2 text-sm ${mutedCls}`}> <p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
{t("plex.info.director")}:{" "} <span>{t("plex.info.director")}:</span>
{detail.directors.map((d, i) => ( {detail.directors.map((d) => (
<span key={d}> <Filterable
<Filterable key={d}
className="font-medium" tag
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined} onDark={overlay}
> onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
{d} >
</Filterable> {d}
{i < detail.directors.length - 1 ? ", " : ""} </Filterable>
</span>
))} ))}
</p> </p>
)} )}
{detail.summary && ( {detail.summary && (
<p <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} {detail.summary}
</p> </p>
@@ -679,100 +678,18 @@ export default function PlexInfo({
</div> </div>
{/* End hero panel. */} {/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords / {/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
Crew sit side by side (each its natural height, items-start) so no single box hogs the full strips first (the owner's most used), then the crew, then the cast — beside a narrow
width with dead space; each is independently hideable from the customize menu. */} sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
{!overlay && (showFacts || showKeywords || showCrew) && ( toggleable, so the layout is built from what is ON: a column with nothing in it is not
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3"> rendered and the other takes the full width; inside a column the sections stack, so a
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />} hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The
{showKeywords && ( overlay (over the running player) stays lean: its strips and its cast, stacked. */}
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} /> {(() => {
)} const collections = (detail.collections ?? []).filter(
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />} (col) => !hiddenSources.includes(col.source),
</div> );
)} const strips = collections.map((col) => (
{/* 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) => (
<div key={col.id} className={overlay ? "mt-6" : `${SURFACE.panel} p-4 sm:p-5`}> <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"> <div className="mb-2 flex items-center justify-between gap-3">
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}> <h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
@@ -795,62 +712,134 @@ export default function PlexInfo({
</div> </div>
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}> <Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m, i) => ( {col.items.map((m, i) => (
<Pressable // The one media card, as on every other strip (E28 S3h). A grid cell, so the
selectable // card fills the slot the carousel stretches to its tallest card.
key={m.id} <div key={m.id} className="grid w-37.5 shrink-0 grid-cols-1">
{...navItemAttrs(i)} <PlexPosterCard
onClick={() => onPlayItem?.(m.id)} card={{ ...m, type: "movie" }}
disabled={!onPlayItem} shelf
className="group/col w-28 shrink-0" navIndex={i}
> onOpen={() => onPlayItem?.(m.id)}
<div />
className={`relative aspect-2/3 overflow-hidden rounded-lg border ${ </div>
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>
))} ))}
</Carousel> </Carousel>
</div> </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> </div>
{editorOpen && library && ( {editorOpen && library && (
<PlexCollectionEditor <PlexCollectionEditor
+8 -3
View File
@@ -1,12 +1,17 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile"; import { hueOf, hueTileGradient } from "../lib/hueTile";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { MEDIA_IMG } from "./ui/mediaCardStyles";
import { Pressable } from "./ui/Pressable"; 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}), // 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 // 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 // (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. // 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({ export default function PosterTile({
title, title,
subtitle, subtitle,
@@ -32,11 +37,11 @@ export default function PosterTile({
onClick={onClick} onClick={onClick}
title={title} title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))} {...(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. */} {/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
<div <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 aria-hidden
style={posters.length === 0 ? { background: band } : undefined} style={posters.length === 0 ? { background: band } : undefined}
> >
@@ -48,7 +53,7 @@ export default function PosterTile({
src={`/api/plex/image/${posters[i]}`} src={`/api/plex/image/${posters[i]}`}
alt="" alt=""
loading="lazy" loading="lazy"
className="h-full w-full object-cover" className={MEDIA_IMG}
/> />
) : ( ) : (
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} /> <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 { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton"; import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; 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. */ /** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean }; 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 // 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 // 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. // 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, video,
onState, onState,
onToggleSave, onToggleSave,
@@ -64,8 +74,8 @@ function Actions({
onToggleSave(video.id, !video.saved); onToggleSave(video.id, !video.saved);
}; };
const variant = media ? "media" : "ghost"; const variant = media ? "media" : "ghost";
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the // Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
// one-line row's w-32 column (ROW_COL.actions). // one-line row floats three of them (no "…": its own stays put) over its date on hover.
return ( return (
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}> <div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton <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 {/* 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 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 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> <span>
{StatusIcon && ( {StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}> <span role="img" aria-label={status.label} title={status.label}>
@@ -220,7 +230,10 @@ function Thumb({
}; };
return ( 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 {/* 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). */} Tab stop of every card as "44:10" (E28 S3d4). */}
<a <a
@@ -229,7 +242,7 @@ function Thumb({
rel="noreferrer" rel="noreferrer"
aria-label={video.title ?? undefined} aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)} 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 ? ( {video.thumbnail_url ? (
<img <img
@@ -239,7 +252,7 @@ function Thumb({
decoding="async" decoding="async"
width={1280} width={1280}
height={720} 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" /> <div className="w-full h-full" />
@@ -247,7 +260,8 @@ function Thumb({
{video.duration_seconds != null ? ( {video.duration_seconds != null ? (
<span <span
className={clsx( 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. // The hover row takes this corner; the duration steps aside while it shows.
bar && HOVER_YIELD, 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 `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. */} permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
{onOpen && ( {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 ? ( {inProgress ? (
<> <>
{small ? ( {small ? (
@@ -338,15 +352,9 @@ function Thumb({
</div> </div>
)} )}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */} {/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && ( <MediaProgress pct={pct} />
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</a> </a>
{bar && ( {bar && <div className={MEDIA_BAR}>{bar}</div>}
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
)}
</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 // 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. // LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = { 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 channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16 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 // 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 // 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. // 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 = { const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel, channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta, meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
@@ -401,6 +409,7 @@ function VideoCard({
menuButtonProps, menuButtonProps,
menuOpen = false, menuOpen = false,
navIndex, navIndex,
shelf = false,
}: { }: {
video: Video; video: Video;
view: FeedView; view: FeedView;
@@ -422,6 +431,8 @@ function VideoCard({
menuOpen?: boolean; menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */ /** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number; 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 { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
@@ -493,8 +504,8 @@ function VideoCard({
title={titleClamped ? (video.title ?? undefined) : undefined} title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx( className={clsx(
// min-h-6: a one-line title is still a 24 target (B1). // 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", "min-h-6 select-text hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2", noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
)} )}
> >
{video.title} {video.title}
@@ -523,7 +534,7 @@ function VideoCard({
// the toggles inside it are gone and only the "…" is left. // the toggles inside it are gone and only the "…" is left.
const actions = ( const actions = (
<div className="reveal"> <div className="reveal">
<Actions <VideoHoverActions
video={video} video={video}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
@@ -569,29 +580,53 @@ function VideoCard({
{...(navIndex !== undefined && navItemAttrs(navIndex))} {...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")} 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 {/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */} 128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
<div className="shrink-0 w-32">{actions}</div> end holds a faint "…" (always there: the menu's door on every device), and on hover or
{/* Everything else is shrink-0, so the title is the only thing that can give — which means keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
from the right as the row narrows, least important first: a title with no date beats a Everything but the title is shrink-0, so the title is the only thing that can give —
date with no title. `width` is the ROW's own, so this holds whatever the rail and the which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
filter panel are doing. Until the first measure lands (width 0) show everything: the Columns drop from the right as the row narrows, least important first: a title with no
row is off-screen for that frame anyway, and guessing narrow would flash. */} 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> <div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</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 {/* 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 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. */} took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && ( {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)} {viewsCell(false)}
</div> </div>
)} )}
{fits(ROW_FITS.published) && ( {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. */} {/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && ( {pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40"> <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 // 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 // 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 // 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 ( return (
<div <MediaCard
data-testid="video-card" rootProps={{
data-video-id={video.id} "data-testid": "video-card",
{...menuProps?.(video)} "data-video-id": video.id,
{...(navIndex !== undefined && navItemAttrs(navIndex))} ...menuProps?.(video),
className={clsx( ...(navIndex !== undefined && navItemAttrs(navIndex)),
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`, }}
"flex flex-col", className="cv-card"
spec.dense ? "p-2" : "p-2.5", dense={spec.dense}
watched && "opacity-55", 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
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at // and would spill straight out of it.
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide picture={
and would spill straight out of it. */} <Thumb
<Thumb video={video}
video={video} className="aspect-video"
className="aspect-video" onOpen={onOpen}
onOpen={onOpen} small={spec.dense}
small={spec.dense} bar={
bar={ <VideoHoverActions
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media /> video={video}
} onState={onState}
/> onToggleSave={onToggleSave}
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}> more={more}
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the media
gap would leave the title barely half the width. The channel name is still below it. */} />
{!spec.dense && ( }
/>
}
// 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 <Avatar
src={video.channel_thumbnail} src={video.channel_thumbnail}
fallback={video.channel_title ?? ""} fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5" 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 && ( title={title}
<MoreButton meta={
className="touch-only -mr-1 shrink-0" shelf ? (
label={t("card.menu.more")} channelLink
expanded={more.open} ) : (
menuProps={more.props} <>
/> {channelLink}
)} <div className="text-xs text-muted mt-0.5">
</div> {views}
</div> {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 // 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. // key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); 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 { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "./ui/buttonStyles";
import type { PlexCard, PlexSeasonDetail } from "../lib/api"; import type { PlexCard, PlexSeasonDetail } from "../lib/api";
const movie = (status?: string): PlexCard => ({ const movie = (status?: string): PlexCard => ({
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); 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( render(
<PlexPosterCard <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()} onOpen={vi.fn()}
onToggleWatched={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("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", () => { it("draws no meta line when the card has neither a year nor a rating", () => {
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />); const { container } = render(
expect(screen.queryByText(/synopsis/i)).toBeNull(); <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); 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 { useTranslation } from "react-i18next";
import { CheckCircle2, Circle, Film, ListPlus, Play, Star, Tv2 } from "lucide-react"; import { CheckCircle2, Circle, Info, ListPlus, Star } from "lucide-react";
import { type CSSProperties, type ReactNode, useRef } from "react"; import clsx from "clsx";
import { type ReactNode, useRef } from "react";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
import { navItemAttrs } from "../lib/listNav"; import { navItemAttrs } from "../lib/listNav";
import { endsTextSelection, movedPastSlop } from "../lib/textSelection"; import { endsTextSelection, movedPastSlop } from "../lib/textSelection";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
import { plexAddLabelKey } from "./usePlexCardActions"; import { plexAddLabelKey } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton"; import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable"; 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"; import { SURFACE } from "./ui/surfaces";
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared // 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. */ * inside it, so no control nests in another. */
function HoverBar({ function HoverBar({
watched, watched,
@@ -50,6 +62,7 @@ function HoverBar({
onToggleWatched, onToggleWatched,
addLabel, addLabel,
onAdd, onAdd,
onInfo,
more: m, more: m,
}: { }: {
watched: boolean; watched: boolean;
@@ -57,12 +70,14 @@ function HoverBar({
onToggleWatched?: () => void; onToggleWatched?: () => void;
addLabel: string; addLabel: string;
onAdd?: () => void; 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; more: More;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (!onToggleWatched && !onAdd && !m) return null; if (!onToggleWatched && !onAdd && !onInfo && !m) return null;
return ( 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 && ( {onToggleWatched && (
<IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}> <IconButton variant="media" onClick={onToggleWatched} label={watchedLabel}>
<WatchedIcon watched={watched} /> <WatchedIcon watched={watched} />
@@ -73,6 +88,11 @@ function HoverBar({
<ListPlus /> <ListPlus />
</IconButton> </IconButton>
)} )}
{onInfo && (
<IconButton variant="media" onClick={onInfo} label={t("plex.openShow")}>
<Info />
</IconButton>
)}
{m && ( {m && (
<MoreButton <MoreButton
variant="media" 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). */ /** The hover row on a picture that is not a card's (Home's hero): the card's own watched and add
function Caption({ * 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, onClick,
more: m, shelf = false,
className = "",
children, children,
}: { }: {
onClick?: () => void; onClick?: () => void;
more: More; /** One line, cut with an ellipsis (a shelf). The whole title is in the card's preview. */
className?: string; shelf?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {
const { t } = useTranslation();
const pressedAt = useRef<{ x: number; y: number } | null>(null); const pressedAt = useRef<{ x: number; y: number } | null>(null);
return ( return (
<div <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={
onClick && onClick &&
((e) => { ((e) => {
@@ -114,22 +161,75 @@ function Caption({
onMouseDown={(e) => { onMouseDown={(e) => {
pressedAt.current = { x: e.clientX, y: e.clientY }; 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}`} className={clsx(
style={{ background: "color-mix(in srgb, var(--card) 60%, transparent)" }} "min-h-6",
> mediaTitleClass(true, shelf),
<div className="min-w-0 flex-1">{children}</div> onClick && "cursor-pointer hover:text-accent",
{m && (
<MoreButton
className="touch-only -mr-1 -my-0.5 shrink-0"
label={t("card.menu.more")}
expanded={m.open}
menuProps={m.props}
/>
)} )}
>
{children}
</div> </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({ export function PlexPosterCard({
card, card,
onOpen, onOpen,
@@ -139,6 +239,10 @@ export function PlexPosterCard({
menuButtonProps, menuButtonProps,
menuOpen = false, menuOpen = false,
navIndex, navIndex,
wide = false,
shelf = false,
next,
onInfo,
}: { }: {
card: PlexCard; card: PlexCard;
onOpen: () => void; onOpen: () => void;
@@ -155,163 +259,149 @@ export function PlexPosterCard({
menuOpen?: boolean; menuOpen?: boolean;
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */ /** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
navIndex?: number; 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 { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const preview = useMediaPreview();
const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down 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 watched = card.status === "watched";
const actedWatched = acted.status === "watched";
const runtime = formatRuntime(card.duration_seconds); const runtime = formatRuntime(card.duration_seconds);
const pct = const pct = ep ? progressPct(ep) : watched ? 0 : progressPct(card);
inProgress && card.duration_seconds
? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100))
: 0;
const m = more(card, menuButtonProps, menuOpen); const m = more(card, menuButtonProps, menuOpen);
const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(card) : undefined; const toggle = isPlayable && onToggleWatched ? () => onToggleWatched(acted) : undefined;
const add = isPlayable && onAdd ? () => onAdd(card) : undefined; const add = isPlayable && onAdd ? () => onAdd(acted) : undefined;
// A corner pill steps aside while the hover row covers its corner; at rest, and on touch, it stays. const epCode = ep && { season: ep.season_number ?? "?", episode: ep.episode_number ?? "?" };
const yieldTop = toggle || add || m ? HOVER_YIELD : ""; // 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 ( return (
<div <>
className="group text-left" <MediaCard
{...menuProps?.(card)} dense
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))} rootProps={preview.rootProps({
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll). ...menuProps?.(card),
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties} ...(navIndex !== undefined && navItemAttrs(navIndex, card.title)),
> })}
<div className="relative"> // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll);
<div // a carousel turns it off (index.css `.media-rail`).
onClick={onOpen} className="cv-poster"
role="button" picture={
tabIndex={0} <PlexPicture
data-nav-open="" aspect={landscape ? "wide" : "poster"}
onKeyDown={(e) => { thumb={(wide && card.type !== "episode" && card.art) || card.thumb}
if (e.key === "Enter" || e.key === " ") { label={card.title}
e.preventDefault(); onOpen={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> </span>
}
summary={card.summary}
/>
</>
);
}
{/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with /** The picture's rest-state marks, in the corners the hover row leaves free (it docks bottom-right). */
the synopsis + rating (the info you'd otherwise click through for). */} function WatchedBadge() {
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100"> const { t } = useTranslation();
<div className="absolute inset-0 bg-black/25" /> return <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5")}>{t("plex.watched")}</span>;
<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>
{pct > 0 && ( function InProgressBadge() {
<div className="absolute inset-x-0 bottom-0 z-base h-1 bg-black/40"> const { t } = useTranslation();
<div className="h-full bg-accent" style={{ width: `${pct}%` }} /> return (
</div> <span className={clsx(MEDIA_BADGE, "top-1.5 right-1.5 text-accent")}>
)} {t("plex.inProgress")}
</div> </span>
<HoverBar );
watched={watched} }
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={toggle} /** The bottom-right fact (a length, a count): it steps aside while the hover row covers its corner;
addLabel={t(plexAddLabelKey(card))} * at rest, and on touch, it stays. */
onAdd={add} function CornerBadge({ yields, children }: { yields: boolean; children: ReactNode }) {
more={m} return (
/> <span className={clsx(MEDIA_BADGE, "bottom-1.5 right-1.5", yields && HOVER_YIELD)}>
</div> {children}
<Caption onClick={onOpen} more={m}> </span>
<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>
); );
} }
@@ -339,72 +429,45 @@ export function SeasonCard({
const watched = se.status === "watched"; const watched = se.status === "watched";
const m = more(se, menuButtonProps, menuOpen); const m = more(se, menuButtonProps, menuOpen);
return ( return (
<div <MediaCard
className="group text-left" dense
{...menuProps?.(se)} rootProps={{
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))} ...menuProps?.(se),
> ...(navIndex !== undefined && navItemAttrs(navIndex, se.title)),
<div className="relative"> }}
<div picture={
onClick={onOpen} // A season opens its episode list: no Play on hover, since the click does not play.
role="button" <PlexPicture
tabIndex={0} aspect="poster"
data-nav-open="" thumb={se.thumb}
onKeyDown={(e) => { label={se.title}
if (e.key === "Enter" || e.key === " ") { onOpen={onOpen}
e.preventDefault(); plays={false}
onOpen(); bar={
} <HoverBar
}} watched={watched}
className={`${SURFACE.card} glass-hover relative aspect-2/3 overflow-hidden transition-all duration-200 cursor-pointer`} watchedLabel={
> watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")
<img }
src={se.thumb} onToggleWatched={onToggleWatched}
alt="" addLabel={t("plex.playlist.addSeason")}
loading="lazy" onAdd={onAdd}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" more={m}
/> />
<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")
} }
onToggleWatched={onToggleWatched} >
addLabel={t("plex.playlist.addSeason")} {watched && <WatchedBadge />}
onAdd={onAdd} {/* A season has no progress bar, so this badge is its sole in-progress cue. */}
more={m} {se.status === "in_progress" && <InProgressBadge />}
/> <CornerBadge yields={!!(onToggleWatched || onAdd || m)}>
</div> {t("plex.series.episodeCount", { count: se.episode_count })}
<Caption onClick={onOpen} more={m} className="text-sm font-medium leading-tight"> </CornerBadge>
<div className="line-clamp-2 hover:text-accent">{se.title}</div> </PlexPicture>
</Caption> }
</div> 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> <h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav> <Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r, i) => ( {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 <PlexPosterCard
card={r} card={r}
shelf
navIndex={i} navIndex={i}
onOpen={() => onOpen(r)} onOpen={() => onOpen(r)}
menuProps={menuProps} menuProps={menuProps}
@@ -548,80 +612,62 @@ export function EpisodeCard({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const watched = ep.status === "watched"; const watched = ep.status === "watched";
const inProgress = (ep.position_seconds ?? 0) > 0 && !watched; const pct = watched ? 0 : progressPct(ep);
const pct =
inProgress && ep.duration_seconds
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0;
const m = more(ep, menuButtonProps, menuOpen); const m = more(ep, menuButtonProps, menuOpen);
const runtime = formatRuntime(ep.duration_seconds);
return ( return (
<div <MediaCard
className="group" dense
{...menuProps?.(ep)} rootProps={{
{...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))} ...menuProps?.(ep),
> ...(navIndex !== undefined && navItemAttrs(navIndex, ep.title)),
<div className="relative"> }}
<div picture={
onClick={onPlay} <PlexPicture
role="button" aspect="wide"
tabIndex={0} thumb={ep.thumb}
data-nav-open="" label={ep.title}
onKeyDown={(e) => { onOpen={onPlay}
if (e.key === "Enter" || e.key === " ") { plays
e.preventDefault(); pct={pct}
onPlay(); bar={
} <HoverBar
}} watched={watched}
className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")}
onToggleWatched={onToggleWatched}
addLabel={t("plex.playlist.addEpisode")}
onAdd={onAdd}
more={m}
/>
}
> >
<img {watched && <WatchedBadge />}
src={ep.thumb} {runtime && (
alt="" <CornerBadge yields={!!(onToggleWatched || onAdd || m)}>{runtime}</CornerBadge>
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>
)} )}
{pct > 0 && ( </PlexPicture>
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40"> }
<div className="h-full bg-accent" style={{ width: `${pct}%` }} /> title={
</div> <CardTitle onClick={onPlay}>
{!withShowTitle && ep.episode_number != null && (
<span className="text-muted mr-1">{ep.episode_number}.</span>
)} )}
</div> {ep.title}
<HoverBar </CardTitle>
watched={watched} }
watchedLabel={watched ? t("plex.markUnwatched") : t("plex.markWatched")} meta={
onToggleWatched={onToggleWatched} withShowTitle &&
addLabel={t("plex.playlist.addEpisode")} ep.show_title && (
onAdd={onAdd} <div className="truncate text-xs text-muted">
more={m}
/>
</div>
<Caption more={m}>
{withShowTitle && ep.show_title && (
<div className="text-2xs text-muted truncate">
{ep.show_title} {ep.show_title}
{ep.season_number != null && ep.episode_number != null {ep.season_number != null && ep.episode_number != null
? ` · S${ep.season_number}·E${ep.episode_number}` ? ` · S${ep.season_number}·E${ep.episode_number}`
: ""} : ""}
</div> </div>
)} )
<div className="text-sm font-medium leading-tight"> }
{!withShowTitle && <span className="text-muted mr-1">{ep.episode_number}.</span>} more={m}
{ep.title} moreLabel={t("card.menu.more")}
</div> />
<div className="text-2xs text-muted mt-0.5">{formatRuntime(ep.duration_seconds)}</div>
</Caption>
</div>
); );
} }
+22 -18
View File
@@ -262,7 +262,7 @@ export function PlexPersonView({
</div> </div>
</div> </div>
{d.biography && ( {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} {d.biography}
</p> </p>
)} )}
@@ -277,8 +277,9 @@ export function PlexPersonView({
keyboardNav keyboardNav
> >
{d.known_for.map((c, i) => ( {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 <PlexPosterCard
shelf
card={c} card={c}
navIndex={i} navIndex={i}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
@@ -470,9 +471,12 @@ export function PlexShowView({
} }
> >
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading> <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 && ( {show.year != null && (
<Filterable <Filterable
tag
title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined} title={onFilter ? t("plex.info.filterYear", { year: show.year }) : undefined}
onClick={ onClick={
onFilter onFilter
@@ -484,21 +488,19 @@ export function PlexShowView({
</Filterable> </Filterable>
)} )}
{show.content_rating && ( {show.content_rating && (
<span> <Filterable
·{" "} tag
<Filterable onClick={
onClick={ onFilter
onFilter ? () => onFilter({ contentRatings: [show.content_rating!] })
? () => onFilter({ contentRatings: [show.content_rating!] }) : undefined
: undefined }
} >
> {show.content_rating}
{show.content_rating} </Filterable>
</Filterable>
</span>
)} )}
{show.season_count != null && ( {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 // 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"}`} 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 */} {/* 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)} {score.toFixed(1)}
</Pressable> </Pressable>
); );
@@ -538,7 +540,9 @@ export function PlexShowView({
</div> </div>
)} )}
{show.summary && ( {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"> <div className="mt-4 flex flex-wrap gap-2">
{show.resume && ( {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. */} scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
<div <div
ref={setScroller} 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 }} style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group" role="group"
aria-label={label} 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 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` | 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` | | page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` | | 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 * 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. */ * hair before the click handler toggles it shut again. */
hoverAlso?: boolean; 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 /** 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 * 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. */ * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */
@@ -157,13 +161,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
move: false, move: false,
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the // 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). // 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 // 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. // menu, so the "did they mean it" question is mostly answered.
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
handleClose: safePolygon(), 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 // 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 // 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 // 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]. */ /** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = { 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"], "LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"], "NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"], "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 { BatchA } from "./batchA";
import { BatchB } from "./batchB"; import { BatchB } from "./batchB";
import { BatchC } from "./batchC"; import { BatchC } from "./batchC";
import { BatchD } from "./batchD";
import { Chip } from "../components/ui/Chip"; import { Chip } from "../components/ui/Chip";
import { Slider } from "../components/ui/NativeControls"; import { Slider } from "../components/ui/NativeControls";
import { SURFACE } from "../components/ui/surfaces"; 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"> <a href="#decided-c" className="rounded-md px-1.5 py-0.5 text-success state-layer">
C ✓ C ✓
</a> </a>
<a href="#decided-s3h" className="rounded-md px-1.5 py-0.5 text-success state-layer">
S3h ✓
</a>
</nav> </nav>
</div> </div>
@@ -114,6 +118,14 @@ export default function DesignLab() {
</p> </p>
</header> </header>
<BatchC /> <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> </main>
</div> </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", "moveRight": "Right",
"moveLast": "To the end", "moveLast": "To the end",
"moveFailed": "Could not reorder the shelf.", "moveFailed": "Could not reorder the shelf.",
"moveSaving": "Saving the new order…" "moveSaving": "Saving the new order…",
"openPlaylist": "Open the playlist"
}, },
"pin": { "pin": {
"menu": "Comfort shelf", "menu": "Comfort shelf",
+5 -2
View File
@@ -125,6 +125,9 @@
"menu": "Media", "menu": "Media",
"markWatched": "Mark watched", "markWatched": "Mark watched",
"markUnwatched": "Mark unwatched", "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.", "watchedFailed": "Couldn't update the watched state — please try again.",
"seasons": "{{count}} seasons", "seasons": "{{count}} seasons",
"unified": { "unified": {
@@ -243,7 +246,7 @@
"title": "Media info", "title": "Media info",
"customize": "Customize view", "customize": "Customize view",
"prefArtBg": "Faint backdrop", "prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew", "prefCast": "Cast",
"prefRelated": "Related shows", "prefRelated": "Related shows",
"prefFacts": "Details", "prefFacts": "Details",
"prefKeywords": "Keywords", "prefKeywords": "Keywords",
@@ -258,7 +261,7 @@
}, },
"openImdb": "Open on IMDb", "openImdb": "Open on IMDb",
"director": "Director", "director": "Director",
"cast": "Cast & crew", "cast": "Cast",
"markWatched": "Mark watched", "markWatched": "Mark watched",
"markUnwatched": "Mark unwatched", "markUnwatched": "Mark unwatched",
"clearResume": "Clear resume position", "clearResume": "Clear resume position",
+2 -1
View File
@@ -39,7 +39,8 @@
"moveRight": "Jobbra", "moveRight": "Jobbra",
"moveLast": "A polc végére", "moveLast": "A polc végére",
"moveFailed": "Nem sikerült átrendezni a polcot.", "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": { "pin": {
"menu": "Komfort polc", "menu": "Komfort polc",
+4 -1
View File
@@ -125,6 +125,9 @@
"menu": "Média", "menu": "Média",
"markWatched": "Megnézettnek jelöl", "markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-né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.", "watchedFailed": "Nem sikerült frissíteni a megnézett állapotot — próbáld újra.",
"seasons": "{{count}} évad", "seasons": "{{count}} évad",
"unified": { "unified": {
@@ -258,7 +261,7 @@
}, },
"openImdb": "Megnyitás az IMDb-n", "openImdb": "Megnyitás az IMDb-n",
"director": "Rendező", "director": "Rendező",
"cast": "Szereplők & stáb", "cast": "Szereplők",
"markWatched": "Megjelölés megnézettként", "markWatched": "Megjelölés megnézettként",
"markUnwatched": "Megnézett visszavonása", "markUnwatched": "Megnézett visszavonása",
"clearResume": "Folytatási pozíció törlése", "clearResume": "Folytatási pozíció törlése",
+20
View File
@@ -142,6 +142,14 @@
border-color: var(--edge-strong); 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 /* 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. */ 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. /* 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; content-visibility: auto;
contain-intrinsic-size: auto 320px; 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 { .cv-row {
content-visibility: auto; content-visibility: auto;
contain-intrinsic-size: auto 96px; contain-intrinsic-size: auto 96px;