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
+58 -1
View File
@@ -5,7 +5,7 @@
// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is
// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache
// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew).
import { describe, it, expect, afterEach, vi } from "vitest";
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -156,3 +156,60 @@ describe("PlexInfo enriched detail (page)", () => {
expect(screen.getByText("IMDb")).toBeTruthy();
});
});
// E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
describe("PlexInfo layout B (page)", () => {
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
// The strips are Carousels, which watch their size.
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
});
const heading = (name: string) => screen.getByRole("heading", { name });
const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement;
const full: PlexItemDetail = {
...detail,
cast: [{ name: "An Actor", role: "Hero" }],
collections: [
{
id: "c1",
title: "The Collection",
source: "plex",
items: [{ id: "m2", type: "movie", title: "Sequel", thumb: "/api/plex/image/m2" }],
},
],
};
it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => {
wrap(<PlexInfo detail={full} variant="page" />);
const coll = heading("The Collection");
const crew = heading("plex.info.crew");
const cast = heading("plex.info.cast");
const facts = heading("plex.info.facts.title");
const follows = (a: HTMLElement, b: HTMLElement) =>
!!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
expect(follows(coll, crew) && follows(crew, cast)).toBe(true);
const main = column(coll);
expect(main.contains(crew) && main.contains(cast)).toBe(true);
const side = column(facts);
expect(side).not.toBe(main);
expect(side).toBe(column(heading("plex.info.keywords")));
// the two columns are the two children of the one grid
expect(main.parentElement!.className).toContain(TWO_COLUMNS);
expect([...main.parentElement!.children]).toEqual([main, side]);
});
it("with nothing for the main column, the sidebar's sections take the full width", () => {
wrap(<PlexInfo detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }} variant="page" />);
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
const side = column(heading("plex.info.facts.title"));
expect(side).toBe(column(heading("plex.info.keywords")));
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
expect([...side.parentElement!.children]).toEqual([side]);
});
});