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
@@ -1,13 +1,12 @@
// @vitest-environment jsdom
//
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
// Contract of a video's details panel (E8·S3; opened from the video's menu since E28 C1): the
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui, so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -30,7 +29,7 @@ vi.mock("../lib/api", () => ({
},
}));
import VideoInfoCard from "./VideoInfoCard";
import VideoDetailsAt from "./VideoInfoCard";
import type { Video } from "../lib/api";
beforeAll(() => {
@@ -72,33 +71,31 @@ const video = (over: Partial<Video> = {}): Video =>
...over,
}) as Video;
const open = (v: Video = video()) => {
const open = (v: Video = video(), onClose = vi.fn()) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const anchor = document.createElement("div");
document.body.appendChild(anchor);
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={v} className="info-trigger" />
<VideoDetailsAt video={v} anchor={anchor} onClose={onClose} />
</FloatingTree>
</QueryClientProvider>,
);
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
return { onClose };
};
describe("VideoInfoCard", () => {
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<FloatingTree>
<VideoInfoCard video={video()} className="info-trigger" />
</FloatingTree>
</QueryClientProvider>,
);
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
expect(screen.queryByText("The full untruncated title")).toBeNull();
// `enabled: open` gates the queries — nothing fired while closed.
expect(videoDetail).not.toHaveBeenCalled();
expect(channelDetail).not.toHaveBeenCalled();
describe("VideoDetailsAt", () => {
// Escape is measured in the browser: jsdom has no layout, so the anchor reads as hidden and the
// layer stack never takes the panel's Escape.
it("is a named dialog", async () => {
videoDetail.mockResolvedValue({ id: "v1", description: null });
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
tags.mockResolvedValue([]);
open();
await screen.findByText("The full untruncated title");
// Named by its aria-label (the role query cannot see into the unpositioned, hidden surface).
expect(document.querySelector("[role=dialog]")?.getAttribute("aria-label")).toBe("card.info");
});
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {