diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index e502722c..f50c573f 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -17,6 +17,7 @@ import { import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; +import VideoInfoCard from "./VideoInfoCard"; import clsx from "clsx"; import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; @@ -105,6 +106,17 @@ function Actions({ ); } +// 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-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85", +); +const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg"); + // 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. function openInApp( @@ -312,6 +324,10 @@ function Thumb({ )} )} + {/* 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. */} + {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
@@ -330,7 +346,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: 192, // w-48 — six buttons at 28 + five 4px gaps = 188 + actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger channel: 128, // w-32 meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps views: 64, // w-16 @@ -508,8 +524,13 @@ 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. */} -
{actions}
+ 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. */} +
+
{actions}
+ +
{/* 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 diff --git a/frontend/src/components/VideoInfoCard.dom.test.tsx b/frontend/src/components/VideoInfoCard.dom.test.tsx new file mode 100644 index 00000000..a175a3b0 --- /dev/null +++ b/frontend/src/components/VideoInfoCard.dom.test.tsx @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +// +// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open 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. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + }; +}); + +const videoDetail = vi.fn(); +const channelDetail = vi.fn(); +const tags = vi.fn(); +vi.mock("../lib/api", () => ({ + api: { + videoDetail: (...a: unknown[]) => videoDetail(...a), + channelDetail: (...a: unknown[]) => channelDetail(...a), + tags: (...a: unknown[]) => tags(...a), + }, +})); + +import VideoInfoCard from "./VideoInfoCard"; +import type { Video } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const video = (over: Partial