diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx
index 737e7f4d..70a55c88 100644
--- a/frontend/src/components/PlexInfo.dom.test.tsx
+++ b/frontend/src/components/PlexInfo.dom.test.tsx
@@ -300,6 +300,17 @@ describe("PlexInfo — the hero's second column (page)", () => {
expect(screen.getByTestId("detail-aside").textContent).toContain(`Person ${CREW_SHOWN + 1}`);
expect(screen.queryByRole("button", { name: "plex.info.crewMore" })).toBeNull();
});
+
+ // E32 S2: the cast row is the shared CastRow now; the page and the player's overlay hand it their
+ // own palette. Only the overlay sits on the dark art, so only it may carry the white text.
+ it.each([
+ ["page", false],
+ ["overlay", true],
+ ] as const)("the %s variant's cast row takes its own palette", (variant, white) => {
+ wrap();
+ expect(screen.getByText("An Actor").className.includes("text-white/90")).toBe(white);
+ expect(screen.getByText("Hero").className.includes("text-muted")).toBe(!white);
+ });
});
// E28 review D1 + D2: a collection sibling is a Plex card like any other — its "…" menu, its watched
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 7d9d57f6..1242b324 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -19,7 +19,6 @@ import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexPlayable";
-import { navItemAttrs } from "../lib/listNav";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
@@ -27,7 +26,7 @@ import { SURFACE } from "./ui/surfaces";
import { Badge } from "./ui/Badge";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
-import { PlexPosterCard } from "./plexCards";
+import { CastRow, PlexPosterCard } from "./plexCards";
import { PlexMenu } from "./PlexMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { usePlexCardActions } from "./usePlexCardActions";
@@ -594,71 +593,12 @@ export default function PlexInfo({
{t("plex.info.cast")}
-
- {cast.map((c, i) => {
- const clickable = !!(onOpenPerson || onFilter);
- const inner = (
- <>
-
- {c.thumb ? (
-

- ) : (
-
- {c.name.charAt(0)}
-
- )}
-
-
- {c.name}
-
- {c.role && (
-
- {c.role}
-
- )}
- >
- );
- return clickable ? (
-
- 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}
-
- ) : (
-
- {inner}
-
- );
- })}
-
+
);
if (overlay)
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index d580e6cc..ed1e769e 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -528,69 +528,97 @@ export function CastStrip({
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
- const clickable = !!(onOpenPerson || onFilter);
return (
{t("plex.info.cast")}
-
- {cast.map((c, i) => {
- const inner = (
- <>
-
- {c.thumb ? (
-

- ) : (
-
- {c.name.charAt(0)}
-
- )}
-
- {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
- same text height and the row of circles sits on one baseline regardless of name length. */}
-
- {c.name}
-
- {c.role && (
-
- {c.role}
+
+
+ );
+}
+
+// The ONE cast carousel: a show's page (CastStrip) and a title's info (PlexInfo, page or the
+// player's overlay) each frame it with their own heading. Two hand copies of it had drifted apart
+// (gap, lazy pictures, the name's tooltip — E32 S2). `tone="overlay"` is the white-on-art palette.
+export function CastRow({
+ cast,
+ tone = "panel",
+ onFilter,
+ onOpenPerson,
+}: {
+ cast: PlexCastMember[];
+ tone?: "panel" | "overlay";
+ onFilter?: (patch: Partial
) => void;
+ onOpenPerson?: (name: string) => void;
+}) {
+ const { t } = useTranslation();
+ const clickable = !!(onOpenPerson || onFilter);
+ const overlay = tone === "overlay";
+ return (
+
+ {cast.map((c, i) => {
+ const inner = (
+ <>
+
+ {c.thumb ? (
+

+ ) : (
+
+ {c.name.charAt(0)}
)}
- >
- );
- return clickable ? (
-
- 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}
-
- ) : (
-
- {inner}
- );
- })}
-
-
+ {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
+ same text height and the row of circles sits on one baseline regardless of name length. */}
+
+ {c.name}
+
+ {c.role && (
+
+ {c.role}
+
+ )}
+ >
+ );
+ return clickable ? (
+
(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}
+
+ ) : (
+
+ {inner}
+
+ );
+ })}
+
);
}