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