("a[href='https://example.com/c']")!;
+ // inline-block: an inline link's box is the font's height (21px) whatever its line height.
+ for (const a of [title, channel])
+ expect(a.className.split(/\s+/)).toEqual(
+ expect.arrayContaining(["inline-block", "leading-6"]),
+ );
+ });
+
it("keeps the named row as it was: title text, and the link on its own line", () => {
const named = { ...bare, display_name: "A clip" };
const { container } = render();
diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx
index 9ea89536..4b48e4da 100644
--- a/frontend/src/components/downloadRow.tsx
+++ b/frontend/src/components/downloadRow.tsx
@@ -211,7 +211,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
target="_blank"
rel="noopener noreferrer"
title={url}
- className="text-muted hover:text-accent hover:underline"
+ className="inline-block max-w-full truncate align-top leading-6 text-muted hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{name}
@@ -274,7 +274,7 @@ export function DownloadRow({
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${job.source_url}`}
- className="hover:text-accent hover:underline"
+ className="inline-block max-w-full truncate align-top leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{jobTitle(job)}
diff --git a/frontend/src/components/mediaPicture.contract.test.ts b/frontend/src/components/mediaPicture.contract.test.ts
new file mode 100644
index 00000000..dc8755f1
--- /dev/null
+++ b/frontend/src/components/mediaPicture.contract.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import videoCard from "./VideoCard.tsx?raw";
+
+import {
+ MEDIA_HOVER_LAYER,
+ MEDIA_PICTURE_CONTAINER,
+ MEDIA_RESUME_LAYER,
+ mediaPictureBox,
+} from "./ui/mediaCardStyles";
+
+// E31 S5, measured: at text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the 37px hover
+// row (the Play and the row grow with the root font, the picture's height does not). A narrow picture
+// (under 14rem, so the cut moves with the text size) centres what it carries above the row instead.
+describe("a media picture", () => {
+ it("is a size container, so what sits on it answers its width", () => {
+ expect(mediaPictureBox("wide")).toContain(MEDIA_PICTURE_CONTAINER);
+ expect(mediaPictureBox("poster")).toContain(MEDIA_PICTURE_CONTAINER);
+ // The video card draws its own box (it is not a mediaPictureBox).
+ expect(videoCard).toContain('clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)');
+ });
+
+ it.each([
+ ["the Play", MEDIA_HOVER_LAYER],
+ ["the Continue + Restart pair", MEDIA_RESUME_LAYER],
+ ])("leaves the hover row its band when narrow: %s", (_, layer) => {
+ expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-10");
+ });
+});
diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx
index 0c791a9f..d580e6cc 100644
--- a/frontend/src/components/plexCards.tsx
+++ b/frontend/src/components/plexCards.tsx
@@ -12,12 +12,13 @@ import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
-import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
+import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
+ MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
@@ -234,8 +235,7 @@ function PlexPicture({
{resumable && (
/** The box that holds the picture at its ratio. `min-h-0`: an aspect-ratio box otherwise grows to
* its content's height, so a square channel avatar made a 16:9 slot square (measured 182×182). */
export const mediaPictureBox = (aspect: "poster" | "wide") =>
- clsx("relative min-h-0", aspect === "poster" ? "aspect-2/3" : "aspect-video");
+ clsx(
+ "relative min-h-0",
+ MEDIA_PICTURE_CONTAINER,
+ aspect === "poster" ? "aspect-2/3" : "aspect-video",
+ );
diff --git a/frontend/src/i18n/locales/en/player.json b/frontend/src/i18n/locales/en/player.json
index 93210443..ad2d6cd1 100644
--- a/frontend/src/i18n/locales/en/player.json
+++ b/frontend/src/i18n/locales/en/player.json
@@ -23,6 +23,7 @@
"closeEsc": "Close (Esc)",
"description": "Description",
"noDescription": "No description.",
+ "descriptionFailed": "Couldn't load the description",
"channel": "Channel",
"views": "{{formattedCount}} views",
"stream": "stream",
diff --git a/frontend/src/i18n/locales/hu/player.json b/frontend/src/i18n/locales/hu/player.json
index 0d4be415..4aadfe84 100644
--- a/frontend/src/i18n/locales/hu/player.json
+++ b/frontend/src/i18n/locales/hu/player.json
@@ -23,6 +23,7 @@
"closeEsc": "Bezárás (Esc)",
"description": "Leírás",
"noDescription": "Nincs leírás.",
+ "descriptionFailed": "Nem sikerült betölteni a leírást",
"channel": "Csatorna",
"views": "{{formattedCount}} megtekintés",
"stream": "élő adás",