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
+140 -76
View File
@@ -27,6 +27,15 @@ import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
} from "./ui/mediaCardStyles";
import { MediaCard, MediaProgress } from "./ui/MediaCard";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
@@ -35,7 +44,8 @@ type More = { props: Record<string, unknown>; open: boolean };
// a "…" for everything else (download, details, copy, hide, reset, pin), which is the list's ONE
// menu (useVideoActions). On a mouse the row shows on hover or keyboard focus; on touch the toggles
// are gone (the "…" and the long-press are the way in) and only the "…" stays.
function Actions({
/** The card's hover row (watched, save, add, "…"); Home's hero docks the same row on its picture. */
export function VideoHoverActions({
video,
onState,
onToggleSave,
@@ -64,8 +74,8 @@ function Actions({
onToggleSave(video.id, !video.saved);
};
const variant = media ? "media" : "ghost";
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail and the
// one-line row's w-32 column (ROW_COL.actions).
// Four 28px buttons: 4 × 28 + 3 × 4 = 124, which fits the small card's 180px thumbnail; the
// one-line row floats three of them (no "…": its own stays put) over its date on hover.
return (
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton
@@ -171,7 +181,7 @@ function RowMetaCells({ video }: { video: Video }) {
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
bare span has no role, and screen readers don't dependably surface title on one), while a
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
in Actions — the label must not tell anyone to click something that doesn't respond. */}
in VideoHoverActions — the label must not tell anyone to click something that doesn't respond. */}
<span>
{StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}>
@@ -220,7 +230,10 @@ function Thumb({
};
return (
<div className={clsx("relative", className)}>
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
<div className={clsx("relative min-h-0", className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10" (E28 S3d4). */}
<a
@@ -229,7 +242,7 @@ function Thumb({
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className="block relative h-full w-full rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow"
className={MEDIA_PICTURE}
>
{video.thumbnail_url ? (
<img
@@ -239,7 +252,7 @@ function Thumb({
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
className={MEDIA_IMG}
/>
) : (
<div className="w-full h-full" />
@@ -247,7 +260,8 @@ function Thumb({
{video.duration_seconds != null ? (
<span
className={clsx(
"absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm",
MEDIA_BADGE,
"bottom-1.5 right-1.5",
// The hover row takes this corner; the duration steps aside while it shows.
bar && HOVER_YIELD,
)}
@@ -278,7 +292,7 @@ function Thumb({
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
{onOpen && (
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
<div className={MEDIA_HOVER_LAYER}>
{inProgress ? (
<>
{small ? (
@@ -338,15 +352,9 @@ function Thumb({
</div>
)}
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
{pct > 0 && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
<MediaProgress pct={pct} />
</a>
{bar && (
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
)}
{bar && <div className={MEDIA_BAR}>{bar}</div>}
</div>
);
}
@@ -359,7 +367,7 @@ function Thumb({
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
more: 28, // the "…" at the row's end (size-7); the quick actions float over the date on hover
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
@@ -373,7 +381,7 @@ const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
const ROW_BASE = ROW_PAD + ROW_TITLE_MIN + ROW_GAP + ROW_COL.more;
const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
@@ -401,6 +409,7 @@ function VideoCard({
menuButtonProps,
menuOpen = false,
navIndex,
shelf = false,
}: {
video: Video;
view: FeedView;
@@ -422,6 +431,8 @@ function VideoCard({
menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
/** On a shelf (a horizontal strip): a one-line title over the channel alone (ui/mediaCardStyles). */
shelf?: boolean;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
@@ -493,8 +504,8 @@ function VideoCard({
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
// min-h-6: a one-line title is still a 24 target (B1).
"min-h-6 select-text font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2",
"min-h-6 select-text hover:text-accent",
noThumb ? "block truncate font-medium leading-snug" : mediaTitleClass(!!spec.dense, shelf),
)}
>
{video.title}
@@ -523,7 +534,7 @@ function VideoCard({
// the toggles inside it are gone and only the "…" is left.
const actions = (
<div className="reveal">
<Actions
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
@@ -569,29 +580,53 @@ function VideoCard({
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. ROW_COL.actions counts their width. */}
<div className="shrink-0 w-32">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
filter panel are doing. Until the first measure lands (width 0) show everything: the
row is off-screen for that frame anyway, and guessing narrow would flash. */}
{/* The title leads (E28 S3h, the owner's pick B, 2026-09-26): the actions used to lead in a
128px column that sat EMPTY at rest, a hole at the start of every row. Now the row's
end holds a faint "…" (always there: the menu's door on every device), and on hover or
keyboard focus the quick toggles float in beside it OVER the date, which steps aside —
the Gmail row. On touch the toggles are gone (reveal-hover) and the "…" stays.
Everything but the title is shrink-0, so the title is the only thing that can give —
which means once the fixed columns outgrow the row, it gives ALL of it and vanishes.
Columns drop from the right as the row narrows, least important first: a title with no
date beats a date with no title. `width` is the ROW's own, so this holds whatever the
rail and the filter panel are doing. Until the first measure lands (width 0) show
everything: the row is off-screen for that frame anyway, and guessing narrow would
flash. */}
<div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelLink}</div>}
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{fits(ROW_FITS.meta) && (
<div className={clsx(!fits(ROW_FITS.views) && HOVER_YIELD)}>
<RowMetaCells video={video} />
</div>
)}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && (
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
<div
className={clsx(
"shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate",
!fits(ROW_FITS.published) && HOVER_YIELD,
)}
>
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
<div className={clsx("shrink-0 w-48 text-sm text-muted truncate", HOVER_YIELD)}>
{published}
</div>
)}
<div className="relative shrink-0">
<div
className={clsx(HOVER_REVEAL, "absolute inset-y-0 right-full mr-1 flex items-center")}
>
<VideoHoverActions video={video} onState={onState} onToggleSave={onToggleSave} />
</div>
{more && (
<MoreButton label={t("card.menu.more")} expanded={more.open} menuProps={more.props} />
)}
</div>
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
@@ -649,56 +684,85 @@ function VideoCard({
}
// The card's action row sits on its picture (the C1 reference: shown on hover, never taking a line
// of its own), and on touch its "…" sits beside the title as a 44px target.
// of its own), and on touch its "…" sits beside the title as a 44px target. The frame, the pinned
// meta line and the picture's pieces are the one media card's (ui/MediaCard), shared with Plex.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering.
// by a pixel or two forever, which shows up as the whole page juddering. The grid stretches the
// card to its row on its own, which is what the pinned meta line needs.
return (
<div
data-testid="video-card"
data-video-id={video.id}
{...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(
`cv-card group ${SURFACE.card} glass-hover transition-all duration-150`,
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55",
)}
>
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
and would spill straight out of it. */}
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<Actions video={video} onState={onState} onToggleSave={onToggleSave} more={more} media />
}
/>
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
gap would leave the title barely half the width. The channel name is still below it. */}
{!spec.dense && (
<MediaCard
rootProps={{
"data-testid": "video-card",
"data-video-id": video.id,
...menuProps?.(video),
...(navIndex !== undefined && navItemAttrs(navIndex)),
}}
className="cv-card"
dense={spec.dense}
dim={watched}
// The small card's image is in the SAME size class as a row's (its column bottoms out at
// 180px), so it needs the icon-only controls just as much — the labelled pair is 194px wide
// and would spill straight out of it.
picture={
<Thumb
video={video}
className="aspect-video"
onOpen={onOpen}
small={spec.dense}
bar={
<VideoHoverActions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
media
/>
}
/>
}
// The small card drops the channel avatar: at its ~180px column the 36px avatar plus the gap
// would leave the title barely half the width. The channel name is still in the meta line.
leading={
!spec.dense && (
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">{textBlock}</div>
{more && (
<MoreButton
className="touch-only -mr-1 shrink-0"
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
</div>
)
}
title={title}
meta={
shelf ? (
channelLink
) : (
<>
{channelLink}
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{/* The small card keeps the relative time and moves the date into its tooltip: at a
shelf's width the full pair wrapped to a third meta line, and the tallest card sets
the whole shelf's height (every card fills its slot). */}
{spec.dense ? (
<span
title={
video.published_at ? formatDate(video.published_at, i18n.language) : undefined
}
>
{relativeTime(video.published_at)}
</span>
) : (
published
)}
</div>
</>
)
}
more={more || undefined}
moreLabel={t("card.menu.more")}
/>
);
}