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:
@@ -132,7 +132,10 @@ export function Carousel({
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
<div
|
||||
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 }}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ domain-free.
|
||||
|
||||
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` |
|
||||
| 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` |
|
||||
| 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
|
||||
* hair before the click handler toggles it shut again. */
|
||||
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
|
||||
* 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. */
|
||||
@@ -157,13 +161,13 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
move: false,
|
||||
// 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).
|
||||
mouseOnly: !isHover,
|
||||
mouseOnly: !isHover || !!opts.pointerOnly,
|
||||
// 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.
|
||||
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -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]. */
|
||||
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"],
|
||||
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
|
||||
"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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user