fix(ui): stable shelf-card menu triggers, a named Button by type, true layout comments

- Home's rail handed each card a fresh `() => menu.triggerProps(c)` per
  render, defeating memo(VideoCard) on every tile whenever the menu's own
  state changed. `useItemTriggers` (ui/useContextMenu) keeps one trigger per
  item, index-aligned; tested for identity across renders.
- The Button accessible-name union had one hole: `<Button icon={…} />` with
  no children and no size typechecked into a nameless button. A textful
  button now needs children or a name; pinned with @ts-expect-error.
- comfortPin's header said the pin belongs "on every card and row", which
  the code never meant (an episode, a season, a person are not pin kinds).
  It now says what is pinnable — and the show page's Related strip, whose
  shows ARE pinnable, gets the pin menu it was missing (and a rem width).
- gridColumns / feedView / VirtualGrid claimed layouts 1024–2560 are
  "byte-identical" under the column cap; the small cards are capped at 2560
  with the filter rail collapsed. The comments say so.
This commit is contained in:
2026-09-19 07:40:48 +02:00
parent 7ae5c57b6b
commit 29875b7367
11 changed files with 79 additions and 20 deletions
+7 -6
View File
@@ -33,7 +33,7 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
import { Button } from "./ui/Button";
import { Carousel } from "./ui/Carousel";
import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu";
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
import SegmentedToggle from "./ui/SegmentedToggle";
import { LoadingState, StateMessage } from "./ui/QueryState";
import VideoCard from "./VideoCard";
@@ -158,6 +158,10 @@ function Rail({
: pin.item(pinTargetForCard(target), target.title)),
];
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
const menuFor = useItemTriggers(cards, menu.triggerProps);
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
// you have not pinned anything teaches nothing about how to fill it.
if (!cards.length) {
@@ -190,10 +194,7 @@ function Rail({
onState={onState}
onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
// Bound to the UNIFIED card, not the native payload the card component holds: the
// menu acts on the shelf ENTRY (its pin id, its place in the order), which the
// `Video` inside it does not carry.
menuProps={() => menu.triggerProps(c)}
menuProps={menuFor[i]}
/>
) : c.plex ? (
<PlexPosterCard
@@ -202,7 +203,7 @@ function Rail({
onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)}
menuProps={menuFor[i]}
/>
) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />