feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel

- Channel page: the banner and identity block fold away once the grid scrolls
  (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds
  at the very top) and the name rides in the tab row. One Back button in both
  states keeps its focus; folded at a tablet width the actions wrap instead of
  pushing the page sideways.
- Plex movie and show pages: the TMDB facts over the key crew are the hero's
  second column (plexDetailAside), the keywords sit under the text; the show
  page shows them for the first time, with the same three toggles. A
  collection's hero is sized to its text.
- YouTube player, 1280px and wider: the title, channel, actions, description
  and queue sit in a side panel beside the video, which takes the window's
  height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel
  while it can and turns the volume elsewhere.
- DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
2026-10-02 02:13:17 +02:00
parent 98d0ca6eeb
commit 78fdcda6bb
16 changed files with 1526 additions and 867 deletions
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
//
// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a
// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold
// away and the name rides in the tab row; back at the top they return. The Back button is one element in
// both states, so a focused Back keeps its focus through the fold.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { CONTENT_ID } from "./ui/landmarks";
import { api, type ChannelDetail, type Me } 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();
document.getElementById(CONTENT_ID)?.remove();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
const BANNER = 'img[src^="https://yt3."]';
// The page's one scroller (PageScroller's <main>), with a scroll position jsdom does not lay out.
function scroller() {
const main = document.createElement("main");
main.id = CONTENT_ID;
let top = 0;
Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true });
Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true });
Object.defineProperty(main, "clientHeight", { value: 600, configurable: true });
document.body.appendChild(main);
return (to: number) =>
act(() => {
top = to;
main.dispatchEvent(new Event("scroll"));
});
}
// Renders the page and waits for the detail to land (the banner shows).
async function renderPage() {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
tag_ids: [],
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const view = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull());
return view;
}
describe("ChannelPage — the header folds while the grid scrolls", () => {
it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => {
const scrollTo = scroller();
const { container } = await renderPage();
const banner = () => container.querySelector(BANNER);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
scrollTo(400);
expect(banner()).toBeNull();
expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk");
scrollTo(10);
expect(screen.queryByTestId("channel-folded")).not.toBeNull();
scrollTo(0);
expect(banner()).not.toBeNull();
expect(screen.queryByTestId("channel-folded")).toBeNull();
});
it("keeps a focused Back focused through the fold", async () => {
const scrollTo = scroller();
await renderPage();
const back = screen.getByTestId("channel-back");
back.focus();
scrollTo(400);
expect(screen.getByTestId("channel-folded")).toBeTruthy();
expect(document.activeElement).toBe(back);
});
});