From e16cdbd16fe429b405acc725872d19b6bd9f6bde Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 23:42:31 +0200 Subject: [PATCH] fix(ui): a card's Play clears its hover row at a large text size; Downloads links and the player description At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the hover row (the Play grows with the root font, the picture's height does not). A picture narrower than 14rem centres the Play and the resume pair above the row; the picture box is a size container for it. The Downloads title and channel links were 21px tall inline boxes; they stand on a 24px line now (B1). The player's description says a failed load, with a retry, instead of 'No description' (both the side panel and the popover). --- frontend/src/components/Home.tsx | 11 ++----- .../src/components/PlayerModal.dom.test.tsx | 23 ++++++++++++++- frontend/src/components/PlayerModal.tsx | 9 ++++++ frontend/src/components/VideoCard.tsx | 3 +- .../src/components/downloadRow.dom.test.tsx | 21 ++++++++++++++ frontend/src/components/downloadRow.tsx | 4 +-- .../components/mediaPicture.contract.test.ts | 29 +++++++++++++++++++ frontend/src/components/plexCards.tsx | 6 ++-- frontend/src/components/ui/mediaCardStyles.ts | 22 ++++++++++++-- frontend/src/i18n/locales/en/player.json | 1 + frontend/src/i18n/locales/hu/player.json | 1 + 11 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/mediaPicture.contract.test.ts diff --git a/frontend/src/components/Home.tsx b/frontend/src/components/Home.tsx index 27c47b98..25fc67b4 100644 --- a/frontend/src/components/Home.tsx +++ b/frontend/src/components/Home.tsx @@ -59,13 +59,14 @@ import { SURFACE } from "./ui/surfaces"; import clsx from "clsx"; import { IconButton } from "./ui/IconButton"; import { MoreButton } from "./ui/MoreButton"; -import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal"; +import { HOVER_YIELD } from "./ui/reveal"; import { MediaResumeButtons } from "./MediaResumeButtons"; import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard"; import { MEDIA_BAR, MEDIA_BADGE, MEDIA_HOVER_LAYER, + MEDIA_RESUME_LAYER, MEDIA_IMG, MEDIA_PICTURE, mediaPictureBox, @@ -595,13 +596,7 @@ function Hero({ {/* A started hero continues on a click, and offers the start as a card does (C5): the pair is a sibling over the picture, never inside its button. */} {started && ( -
+
onPlay(card)} onRestart={() => onPlay(card, 0)} />
)} diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 098759d6..ad8753e6 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -9,7 +9,7 @@ // test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the // fake records what it was asked for — that is how these tests see where playback starts. import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -542,6 +542,27 @@ describe("PlayerModal — the side panel on a wide window", () => { expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video }); + // E31 S5: a failed load read "No description." in the always-open panel — an error drawn as empty. + it("says the description failed to load, with a retry, instead of 'No description'", async () => { + wideWindow(true); + const detail = vi + .spyOn(api, "videoDetail") + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue({ description: "A long description" } as Awaited< + ReturnType + >); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + const panel = screen.getByTestId("player-description"); + expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy(); + expect(within(panel).queryByText("player.noDescription")).toBeNull(); + fireEvent.click(within(panel).getByRole("button", { name: "common.retry" })); + expect(await within(panel).findByText("A long description")).toBeTruthy(); + expect(detail).toHaveBeenCalledTimes(2); + }); + it("keeps the details under the video below 1280 px", () => { wideWindow(false); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 526992b7..7f67c867 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -61,6 +61,7 @@ import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { useBackToClose } from "../lib/history"; import ChannelNameLink from "./ChannelNameLink"; import Toaster from "./ui/Toaster"; +import { StateMessage } from "./ui/QueryState"; import { useOverlayOrder } from "./ui/overlayOrder"; import { afterRemoval } from "../lib/playerQueue"; import { @@ -888,6 +889,10 @@ export default function PlayerModal({ {detail.isLoading ? (
{t("player.loading")}
+ ) : detail.isError && !detail.data ? ( + void detail.refetch()}> + {t("player.descriptionFailed")} + ) : detail.data?.description ? (
{detail.isLoading ? (
{t("player.loading")}
+ ) : detail.isError && !detail.data ? ( + void detail.refetch()}> + {t("player.descriptionFailed")} + ) : detail.data?.description ? (
{renderDescription(detail.data.description, { diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index c1e4f774..c0b126f7 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -35,6 +35,7 @@ import { MEDIA_BADGE, MEDIA_BAR, MEDIA_HOVER_LAYER, + MEDIA_PICTURE_CONTAINER, MEDIA_IMG, MEDIA_PICTURE, mediaTitleClass, @@ -239,7 +240,7 @@ function Thumb({ // min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio // box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px // tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`). -
+
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first Tab stop of every card as "44:10" (E28 S3d4). */} { expect(container.textContent).not.toContain("https://"); }); + // E31 S5 (measured at 21px on the Downloads page): every link in the row stands on a 24px line on + // a mouse (DESIGN B1), as the link line and the failure link already did. + it("gives the title link and the channel link a 24px line", () => { + const { container } = render( + , + ); + const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!; + const channel = container.querySelector("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",