feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)

- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
This commit is contained in:
2026-09-26 01:23:44 +02:00
parent 9d7526ee57
commit 75ae810866
26 changed files with 1189 additions and 557 deletions
+219 -196
View File
@@ -1,4 +1,4 @@
import { memo, useEffect, useRef, useState } from "react";
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "./navigationContext";
import {
@@ -6,8 +6,6 @@ import {
Check,
CheckCheck,
Clock,
Eye,
EyeOff,
Play,
Radio,
RotateCcw,
@@ -16,101 +14,95 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard";
import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatNumber, formatViews, relativeTime } from "../lib/format";
import { isPlainLeftClick } from "../lib/nav";
import { endsTextSelection } from "../lib/textSelection";
import ChannelNameLink from "./ChannelNameLink";
import { IconButton } from "./ui/IconButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { MoreButton } from "./ui/MoreButton";
/** The "…" that opens the video's action list, and whether its menu is the open one. */
type More = { props: Record<string, unknown>; open: boolean };
// E28 C1: the card keeps its state and the everyday toggles — watched, save, add to playlist — and
// 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({
video,
onState,
onResetState,
onToggleSave,
more,
media = false,
dense = false,
}: {
video: Video;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
more?: More;
/** On the thumbnail: the round, dark `media` face (A2). */
media?: boolean;
/** Tighter gap for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: VideoStatus) => (e: React.MouseEvent) => {
const toggleWatched = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onState(video.id, video.status === status ? "new" : status);
onState(video.id, video.status === "watched" ? "new" : "watched");
};
// The reset clears the whole state (incl. an in-progress position the un-watch toggle can't touch).
const resettable = isResettable(video);
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// Every button of the row is the IconButton `sm` rung (28), AddToPlaylist and DownloadButton
// included. `dense` halves the gap for the small card, whose text column bottoms out around 160px;
// six 28px buttons wrap there (the flex-wrap below) until the C1 action list (E28 S3e) moves the
// rare ones into the card's "…".
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).
return (
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap reveal transition", dense ? "gap-0.5" : "gap-1")}>
<div className={clsx("flex", dense ? "gap-0.5" : "gap-1")}>
<IconButton
onClick={act("watched")}
variant={variant}
onClick={toggleWatched}
pressed={video.status === "watched"}
label={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className="reveal-hover"
>
{video.status === "watched" ? <CheckCheck /> : <Check />}
</IconButton>
<IconButton
variant={variant}
onClick={toggleSave}
pressed={!!video.saved}
label={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className="reveal-hover"
>
<Bookmark className={clsx(video.saved && "fill-current")} />
</IconButton>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<IconButton
onClick={act("hidden")}
pressed={video.status === "hidden"}
label={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
>
{video.status === "hidden" ? <Eye /> : <EyeOff />}
</IconButton>
{onResetState && resettable && (
<IconButton
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onResetState(video.id);
}}
label={t("card.resetState")}
>
<RotateCcw />
</IconButton>
<span className="reveal-hover flex">
<AddToPlaylist videoId={video.id} variant={variant} />
</span>
{more && (
<MoreButton
variant={variant}
label={t("card.menu.more")}
expanded={more.open}
menuProps={more.props}
/>
)}
</div>
);
}
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
const INFO_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
const INFO_OVERLAY = clsx(INFO_REVEAL, "absolute bottom-1.5 left-1.5 z-base");
// The thumbnail's hover chrome: shown on hover or keyboard focus on a mouse, removed on touch.
const HOVER_REVEAL =
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity";
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
@@ -121,6 +113,9 @@ function openInApp(
): void {
if (!onOpen || !isPlainLeftClick(e)) return;
e.preventDefault();
// The click that ends selecting the title's words (to copy them) neither plays nor follows the
// link (docs/DESIGN.md "Text stays selectable").
if (endsTextSelection(e.currentTarget)) return;
onOpen(video);
}
@@ -203,6 +198,7 @@ function Thumb({
className,
onOpen,
small = false,
bar,
}: {
video: Video;
className?: string;
@@ -210,6 +206,10 @@ function Thumb({
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
/** The card's hover action row, docked on the picture's bottom-right (the C1 reference). A
* SIBLING of the link, not inside it: a click in the playlists panel it opens would otherwise
* bubble (through the portal, along the React tree) into the link and start playback. */
bar?: ReactNode;
}) {
const { t } = useTranslation();
const inProgress = isInProgress(video);
@@ -223,130 +223,134 @@ function Thumb({
};
return (
// 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
href={video.watch_url}
target="_blank"
rel="noreferrer"
aria-label={video.title ?? undefined}
onClick={(e) => openInApp(e, video, onOpen)}
className={clsx(
"block relative rounded-xl overflow-hidden bg-surface border border-border shadow-md group-hover:shadow-2xl transition-shadow",
className,
)}
>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
) : (
<div className="w-full h-full" />
)}
{video.duration_seconds != null ? (
<span className="absolute bottom-1.5 right-1.5 bg-black/80 text-white text-2xs font-medium px-1.5 py-0.5 rounded-sm">
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
<div className={clsx("relative", 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
href={video.watch_url}
target="_blank"
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"
>
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt=""
loading="lazy"
decoding="async"
width={1280}
height={720}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
) : (
<div className="w-full h-full" />
)}
{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",
// The hover row takes this corner; the duration steps aside while it shows.
bar && "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0",
)}
>
{formatDuration(video.duration_seconds)}
</span>
) : video.live_status === "live" || video.live_status === "upcoming" ? (
<span
className={`absolute bottom-1.5 right-1.5 text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded ${
video.live_status === "live" ? "bg-live text-live-fg" : "bg-black/80 text-white"
}`}
>
{t(`card.${video.live_status}`)}
</span>
) : null}
{video.live_status === "was_live" && (
<span className="absolute top-1.5 left-1.5 bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("card.stream")}
</span>
)}
{video.saved && (
<span className="absolute top-1.5 right-1.5 bg-accent text-accent-fg rounded-full p-1">
<Bookmark className="w-3.5 h-3.5" />
</span>
)}
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
`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">
{inProgress ? (
<>
{small ? (
<IconButton
variant="primary"
size="md"
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</div>
{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">
{inProgress ? (
<>
{small ? (
<IconButton
variant="primary"
size="md"
round
onClick={open(null)}
label={t("card.continue")}
title={t("card.continueTitle")}
>
<Play className="fill-current" />
</IconButton>
) : (
<Button
size="sm"
round
onClick={open(null)}
title={t("card.continueTitle")}
icon={<Play className="fill-current" />}
>
{t("card.continue")}
</Button>
)}
{small ? (
<IconButton
variant="media"
size="md"
onClick={open(0)}
label={t("card.restart")}
title={t("card.restartTitle")}
>
<RotateCcw />
</IconButton>
) : (
<Button
variant="media"
size="sm"
onClick={open(0)}
title={t("card.restartTitle")}
icon={<RotateCcw />}
>
{t("card.restart")}
</Button>
)}
</>
) : (
<IconButton
variant="media"
size={small ? "md" : "lg"}
onClick={open(null)}
label={t("card.play")}
>
<Play className="translate-x-px fill-current" />
</IconButton>
)}
</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>
)}
</a>
{bar && (
<div className={clsx(HOVER_REVEAL, "absolute bottom-2.5 right-1.5 z-base")}>{bar}</div>
)}
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
rest of the hover chrome, dropped on touch. */}
<VideoInfoCard video={video} round className={INFO_OVERLAY} />
{/* 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>
)}
</a>
</div>
);
}
@@ -358,7 +362,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: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
actions: 128, // w-32 (four buttons: 4×28 + 3×4 = 124; E28 C1 moved the rest into the "…")
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
@@ -394,10 +398,11 @@ function VideoCard({
view,
width,
onState,
onResetState,
onToggleSave,
onOpen,
menuProps,
menuButtonProps,
menuOpen = false,
navIndex,
}: {
video: Video;
@@ -405,15 +410,19 @@ function VideoCard({
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: VideoStatus) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
/** Right-click / long-press props for the LIST's one context menu (E24 S3 — the comfort pin).
/** Right-click / long-press props for the LIST's one context menu (E28 C1: the video's actions).
* A FUNCTION, not the already-built props object: `useContextMenu.triggerProps` is stable across
* renders while its return value is a fresh object every call, and a fresh object prop would
* blow past this component's memo for every card in the virtualized feed on every parent
* render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>;
/** The same menu's "…" button props (`useContextMenu.buttonProps`) — a function, for the same memo
* reason. Omitted, the card has no "…" (the menu is still a right-click away). */
menuButtonProps?: (v: Video) => Record<string, unknown>;
/** This card's menu is the open one (the "…" button's `aria-expanded`). */
menuOpen?: boolean;
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
navIndex?: number;
}) {
@@ -478,13 +487,16 @@ function VideoCard({
target="_blank"
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
// A link is dragged, not selected: Chrome starts no selection on one even with the drag off,
// until it is also `user-select: text` (select-text below) — measured in the S3e1 UAT.
draggable={false}
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
// click: the in-app player, resuming where the reader left off.
data-nav-open=""
title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx(
// min-h-6: a one-line title is still a 24 target (B1).
"min-h-6 font-medium leading-snug hover:text-accent",
"min-h-6 select-text font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2",
)}
>
@@ -509,14 +521,19 @@ function VideoCard({
className="text-sm leading-6 text-muted truncate block w-fit max-w-full text-left hover:text-fg"
/>
);
const more = menuButtonProps && { props: menuButtonProps(video), open: menuOpen };
// The rows' action block: shown on hover / focus on a mouse (`.reveal`), always on touch — where
// the toggles inside it are gone and only the "…" is left.
const actions = (
<Actions
video={video}
onState={onState}
onResetState={onResetState}
onToggleSave={onToggleSave}
dense={spec.dense}
/>
<div className="reveal">
<Actions
video={video}
onState={onState}
onToggleSave={onToggleSave}
more={more}
dense={spec.dense}
/>
</div>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
@@ -556,13 +573,8 @@ function VideoCard({
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. This row has no thumbnail to host the info
trigger, so it sits inline right after the actions (still outside their six-button
budget). ROW_COL.actions already counts its width. */}
<div className="shrink-0 flex items-center gap-1">
<div className="w-48">{actions}</div>
<VideoInfoCard video={video} className={INFO_REVEAL} />
</div>
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
@@ -639,10 +651,8 @@ function VideoCard({
);
}
// The flex column is what puts the actions on the card's bottom edge: the grid already stretches
// every card in a row to the tallest one (align-items: stretch), so the column just needs to push
// its last child down. Otherwise the action row floats under a 1-line title on one card and a
// line lower on its 2-line neighbour — the same control landing somewhere different on each.
// 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.
// 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.
@@ -662,7 +672,15 @@ function VideoCard({
{/* 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} />
<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. */}
@@ -673,10 +691,15 @@ function VideoCard({
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
<div className="mt-auto pt-1">{actions}</div>
</div>
<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>
);