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
+123 -7
View File
@@ -5,7 +5,7 @@
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
// key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { PlexPosterCard, SeasonCard, EpisodeCard } from "./plexCards";
import { iconButtonClass } from "./ui/buttonStyles";
import type { PlexCard, PlexSeasonDetail } from "../lib/api";
const movie = (status?: string): PlexCard => ({
@@ -65,21 +66,103 @@ describe("PlexPosterCard a11y", () => {
expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
it("reveals the synopsis + rating on the hover scrim when the card carries them", () => {
// E28 S3h: the one media card — the year and the rating are the meta line under the title (pinned
// to the frame's bottom), the length a badge on the picture. The synopsis is NOT the picture's
// tooltip any more: it lives in the card's preview (below).
it("carries the year and rating in its meta line, the length on the picture", () => {
render(
<PlexPosterCard
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
card={{
...movie(),
year: 2023,
rating: 7.5,
duration_seconds: 6780,
summary: "A quiet synopsis.",
}}
onOpen={vi.fn()}
onToggleWatched={vi.fn()}
/>,
);
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getByText("2023")).toBeTruthy();
expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1)
expect(screen.getByText("1h 53m")).toBeTruthy();
expect(screen.getByRole("button", { name: movie().title }).getAttribute("title")).toBeNull();
});
it("omits the scrim when the card has no synopsis or rating", () => {
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} onToggleWatched={vi.fn()} />);
expect(screen.queryByText(/synopsis/i)).toBeNull();
it("draws no meta line when the card has neither a year nor a rating", () => {
const { container } = render(
<PlexPosterCard card={{ ...movie(), year: null, rating: null }} onOpen={vi.fn()} />,
);
expect(container.querySelector(".mt-auto")).toBeNull();
});
});
// The owner's pick C (2026-09-27): the synopsis shows when the MOUSE rests on the card — not on a
// sweep across the grid, and not on the keyboard walk (a panel at every stop).
describe("PlexPosterCard preview", () => {
afterEach(() => vi.useRealTimers());
const card = { ...movie(), title: "A long title", year: 2020, summary: "A quiet synopsis." };
const root = () =>
screen.getByRole("button", { name: "A long title" }).closest(".cv-poster") as HTMLElement;
it("opens once the mouse has rested on the card, with the whole title and the synopsis", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.mouseEnter(root());
act(() => vi.advanceTimersByTime(600));
expect(screen.queryByText("A quiet synopsis.")).toBeNull(); // a sweep opens nothing
act(() => vi.advanceTimersByTime(400));
expect(screen.getByText("A quiet synopsis.")).toBeTruthy();
expect(screen.getAllByText("A long title")).toHaveLength(2); // the card's and the preview's
});
it("does not open on keyboard focus", () => {
vi.useFakeTimers();
render(<PlexPosterCard card={card} onOpen={vi.fn()} />);
fireEvent.focus(screen.getByRole("button", { name: "A long title" }));
act(() => vi.advanceTimersByTime(2000));
expect(screen.queryByText("A quiet synopsis.")).toBeNull();
});
});
// Home's shelves: a show that plays on is a card for its NEXT episode (the owner, 2026-09-27).
describe("PlexPosterCard with a next episode", () => {
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "", season_count: 6 };
const next: PlexCard = { ...episode(), season_number: 6, episode_number: 16 };
const playGlyph = (c: HTMLElement) => c.querySelector(".reveal-hover.inset-0 svg");
it("draws the Play, names the episode, and acts on it; the ⓘ opens the show", () => {
const onToggleWatched = vi.fn();
const onAdd = vi.fn();
const onInfo = vi.fn();
const { container } = render(
<PlexPosterCard
card={show}
next={next}
onOpen={vi.fn()}
onToggleWatched={onToggleWatched}
onAdd={onAdd}
onInfo={onInfo}
/>,
);
expect(playGlyph(container)).not.toBeNull();
expect(screen.getByText("plex.nextUp")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "plex.markNextWatched" }));
expect(onToggleWatched).toHaveBeenCalledWith(next);
// The episode's own add ("Add episode"), not the show's.
fireEvent.click(screen.getByRole("button", { name: "plex.playlist.addEpisode" }));
expect(onAdd).toHaveBeenCalledWith(next);
fireEvent.click(screen.getByRole("button", { name: "plex.openShow" }));
expect(onInfo).toHaveBeenCalled();
});
it("stays a plain show card without one: no Play, no toggles, no ⓘ", () => {
const { container } = render(
<PlexPosterCard card={show} onOpen={vi.fn()} onToggleWatched={vi.fn()} onInfo={vi.fn()} />,
);
expect(playGlyph(container)).toBeNull();
expect(screen.queryByRole("button", { name: "plex.openShow" })).toBeNull();
expect(screen.queryByRole("button", { name: "plex.markWatched" })).toBeNull();
});
});
@@ -177,3 +260,36 @@ describe("a Plex card's caption and a drag", () => {
expect(onOpen).toHaveBeenCalledTimes(1);
});
});
// On a shelf (Home, the Plex rails, a collection strip) the title is one line, so every card on the
// strip is the same height; a landscape shelf picture is short, so it takes the smaller Play, clear
// of the hover row under it (E28 S3h: at the large size they touched on a 232px slot).
describe("PlexPosterCard on a shelf", () => {
const titleOf = () => screen.getByText("M");
const play = (c: HTMLElement) =>
c.querySelector("span[aria-hidden] > svg.lucide-play")!.parentElement!;
it("cuts the title to one line; a grid card keeps two", () => {
const { unmount } = render(<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf />);
expect(titleOf().className).toContain("truncate");
unmount();
render(<PlexPosterCard card={movie()} onOpen={vi.fn()} />);
expect(titleOf().className).toContain("line-clamp-2");
});
it("only a landscape SHELF card takes the smaller Play", () => {
const large = iconButtonClass({ variant: "media", size: "lg" });
const cases: [boolean, boolean, string][] = [
[true, true, iconButtonClass({ variant: "media", size: "md" })],
[true, false, large], // a poster on a shelf is tall enough
[false, true, large], // a landscape card in a grid is wide enough
];
for (const [shelf, wide, want] of cases) {
const { container, unmount } = render(
<PlexPosterCard card={movie()} onOpen={vi.fn()} shelf={shelf} wide={wide} />,
);
expect(play(container).className, `shelf=${shelf} wide=${wide}`).toBe(want);
unmount();
}
});
});