Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions app/movies/popular/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ vi.mock("@/lib/tmdb", async () => {
});

const { TmdbError } = await import("@/lib/tmdb/errors");

/** A TMDB list page with no titles, as the metadata reads only its counts. */
const tmdbPage = (totalPages: number) => ({
page: 1,
results: [],
totalPages,
totalResults: totalPages * 20,
});

const { generateMetadata } = await import("./page");

const ORIGIN = "https://bestats.example";
Expand All @@ -41,7 +50,7 @@ afterEach(() => {

describe("the popular movies list's metadata (spec 0021, AC-15)", () => {
it("declares /movies/popular as the canonical of page 1, with the site card", async () => {
discoverMovies.mockResolvedValue({ totalPages: 50 });
discoverMovies.mockResolvedValue(tmdbPage(50));

const metadata = await metadataFor();

Expand All @@ -58,7 +67,7 @@ describe("the popular movies list's metadata (spec 0021, AC-15)", () => {
});

it("gives page N its own canonical", async () => {
discoverMovies.mockResolvedValue({ totalPages: 50 });
discoverMovies.mockResolvedValue(tmdbPage(50));

const metadata = await metadataFor("2");

Expand All @@ -69,20 +78,21 @@ describe("the popular movies list's metadata (spec 0021, AC-15)", () => {
});

it("indexes the last page itself", async () => {
discoverMovies.mockResolvedValue({ totalPages: 7 });
// 7 TMDB pages hold 140 titles, 6 pages of 24 (spec 0022, AC-21).
discoverMovies.mockResolvedValue(tmdbPage(7));

const metadata = await metadataFor("7");
const metadata = await metadataFor("6");

expect(metadata.robots).toBeUndefined();
expect(metadata.alternates?.canonical).toBe(
`${ORIGIN}/movies/popular?page=7`,
`${ORIGIN}/movies/popular?page=6`,
);
});

it("is noindex with no canonical for a page past the last one", async () => {
discoverMovies.mockResolvedValue({ totalPages: 7 });
discoverMovies.mockResolvedValue(tmdbPage(7));

const metadata = await metadataFor("8");
const metadata = await metadataFor("7");

expect(metadata).toEqual({
title: TITLE,
Expand Down
14 changes: 11 additions & 3 deletions app/shows/top-rated/page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ function metadataFor(page?: string) {
} as Parameters<typeof generateMetadata>[0]);
}

/** A TMDB list page with no titles, as the metadata reads only its counts. */
const tmdbPage = (totalPages: number) => ({
page: 1,
results: [],
totalPages,
totalResults: totalPages * 20,
});

beforeEach(() => vi.stubEnv("NEXT_PUBLIC_SITE_URL", ORIGIN));

afterEach(() => {
Expand All @@ -36,7 +44,7 @@ afterEach(() => {

describe("the top rated shows list's metadata", () => {
it("reads the top_rated list and names the page by its h1 and note", async () => {
getTvList.mockResolvedValue({ totalPages: 100 });
getTvList.mockResolvedValue(tmdbPage(100));

const metadata = await metadataFor();

Expand All @@ -49,7 +57,7 @@ describe("the top rated shows list's metadata", () => {
});

it("gives page 2 its own canonical", async () => {
getTvList.mockResolvedValue({ totalPages: 100 });
getTvList.mockResolvedValue(tmdbPage(100));

const metadata = await metadataFor("2");

Expand All @@ -59,7 +67,7 @@ describe("the top rated shows list's metadata", () => {
});

it("is noindex past the last page", async () => {
getTvList.mockResolvedValue({ totalPages: 100 });
getTvList.mockResolvedValue(tmdbPage(100));

const metadata = await metadataFor("999");

Expand Down
2 changes: 1 addition & 1 deletion components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`
- **The page background** (`layout/page-background.tsx`, rendered first in `<body>` by the root layout) is fixed at `-z-10`, `aria-hidden` and `pointer-events-none`; its teal gradient (`page-gradient`) and vignette (`page-vignette`) are in the prerendered HTML and are the first paint. `PageBackgroundCanvas` imports `lib/film-scene/start.ts` when the browser is idle, which draws the film strips and bokeh with hand written WebGL2 (no Three.js) in a worker through `OffscreenCanvas`, or on the main thread where a worker cannot draw. Every colour it uses is read from `globals.css` tokens at mount. It stops in a hidden tab, draws one still frame under reduced motion, and falls back to the gradient and vignette alone without WebGL2 or after a lost context. Every fragment is capped so `text-muted-foreground` keeps 4.5:1 over its brightest pixel, read from that token at mount. Nothing in the shell may paint an opaque full page background over it.
- **A dead end is a bug.** `StatePanel`'s `error` and `signed-out` variants cannot render without an action. The discriminated union makes it a type error and the component throws if the type is bypassed.
- **Touch targets** are 44px on mobile and 36px on desktop, the `touch` and `sm` sizes in `buttonVariants`. A control drawn smaller than 44px on mobile carries `hit-area` instead (root AGENTS.md section 3). One accepted exception: the footer's `kris1027` credit link has a 36px tap area, because 44px would mean taking taps from the legal links above it or widening the gap and moving the line.
- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `sizes` of the `PosterGrid` preset it sits in. `catalog` (every title grid) steps 2, 3, 4, 5 and 6 columns so a card stays near the home row card's 180px; `wide` (the season grid only) keeps 2 and 4 (spec 0022). Each preset's columns, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER` in `poster-grid.tsx` change together, and `poster-grid.test.tsx` recomputes the sizes. `POSTER_SIZES` in `poster-card.tsx` is the `catalog` value. The grid is capped at 1200px and centred.
- Posters render through `next/image` with `fill` inside `aspect-[2/3]`, using the `sizes` of the `PosterGrid` preset it sits in. `catalog` (every title grid) steps 2, 3, 4 and 6 columns (no 5, so the 24 title page, `GRID_PAGE_SIZE` in `lib/catalog/grid-page.ts`, fills every row) and a wide screen card stays near the home row card's 180px; `wide` (the season grid only) keeps 2 and 4 (spec 0022). Each preset's columns, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER` in `poster-grid.tsx` change together, and `poster-grid.test.tsx` recomputes the sizes. `POSTER_SIZES` in `poster-card.tsx` is the `catalog` value. The grid is capped at 1200px and centred.
- A missing poster renders the fallback tile at an identical footprint, so the grid never shifts. Never invent a placeholder image or placeholder metadata.

## Layout
Expand Down
22 changes: 11 additions & 11 deletions components/catalog/catalog-list-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ vi.mock("@/lib/tmdb", async () => ({
TmdbError: (await import("@/lib/tmdb/errors")).TmdbError,
}));
vi.mock("@/lib/catalog-list-read", () => ({
readTaggedCatalogList: (...args: unknown[]) => read(...args),
readTaggedCatalogGridPage: (...args: unknown[]) => read(...args),
}));
vi.mock("@/components/catalog/list-cards", () => ({
ShowListCard: (props: {
Expand Down Expand Up @@ -162,17 +162,17 @@ describe("the list page header (AC-9)", () => {

describe("the list page grid (AC-4, AC-9)", () => {
it("reads the page from the URL and shows one card per title", async () => {
read.mockResolvedValue(tvPage(20, 30));
read.mockResolvedValue(tvPage(24, 30));

await renderGrid(TOP_RATED, { page: "2" });

expect(read).toHaveBeenCalledWith(TOP_RATED, 2);
const grid = screen.getByRole("list", { name: "Top rated shows, page 2" });
expect(within(grid).getAllByRole("listitem")).toHaveLength(20);
expect(within(grid).getAllByRole("listitem")).toHaveLength(24);
});

it("reads page 1 when the URL has no page", async () => {
read.mockResolvedValue(tvPage(20, 30));
read.mockResolvedValue(tvPage(24, 30));

await renderGrid();

Expand Down Expand Up @@ -232,7 +232,7 @@ describe("the list page grid (AC-4, AC-9)", () => {
});

it("pages with Page N of M, page 1 as the bare path", async () => {
read.mockResolvedValue(tvPage(20, 30));
read.mockResolvedValue(tvPage(24, 30));

await renderGrid(TOP_RATED, { page: "2" });

Expand All @@ -248,12 +248,12 @@ describe("the list page grid (AC-4, AC-9)", () => {
);
});

it("caps the last page at TMDB's 500", async () => {
read.mockResolvedValue(tvPage(20, 900));
it("ends at the grid's own last page under TMDB's cap", async () => {
read.mockResolvedValue(tvPage(16, 417));

await renderGrid(TOP_RATED, { page: "500" });
await renderGrid(TOP_RATED, { page: "417" });

expect(screen.getByText("Page 500 of 500")).toBeInTheDocument();
expect(screen.getByText("Page 417 of 417")).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Next" }),
).not.toBeInTheDocument();
Expand Down Expand Up @@ -338,13 +338,13 @@ describe("the list page states (AC-10)", () => {
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});

it("holds the grid's place with 20 card skeletons and the pagination skeleton", () => {
it("holds the grid's place with 24 card skeletons and the pagination skeleton", () => {
const fallback = gridBoundary().props.fallback as Element;

const { container } = render(fallback);

const grid = container.querySelector('[aria-hidden="true"]');
expect(grid?.querySelectorAll("li")).toHaveLength(20);
expect(grid?.querySelectorAll("li")).toHaveLength(24);
expect(screen.queryByRole("list")).not.toBeInTheDocument();
});
});
14 changes: 6 additions & 8 deletions components/catalog/catalog-list-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,16 @@ import { RetryLink } from "@/components/retry-link";
import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton";
import { StatePanel } from "@/components/state-panel";
import { ButtonLink } from "@/components/ui/button";
import { GRID_PAGE_SIZE } from "@/lib/catalog/grid-page";
import { type CatalogList, listPageHref, listPath } from "@/lib/catalog/lists";
import { CATALOG_PATHS } from "@/lib/catalog/media-type";
import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages";
import { parsePageParam } from "@/lib/catalog/pages";
import {
readTaggedCatalogList,
readTaggedCatalogGridPage,
type TaggedCatalogPage,
} from "@/lib/catalog-list-read";
import { TmdbError } from "@/lib/tmdb";

/** TMDB's list page size, which the skeleton mirrors. */
const PAGE_SIZE = 20;

const LANDING_LABELS = { tv: "Shows", movie: "Movies" } as const;

type SearchParams = Promise<Record<string, string | string[] | undefined>>;
Expand Down Expand Up @@ -81,7 +79,7 @@ async function ListGrid({

let result: TaggedCatalogPage;
try {
result = await readTaggedCatalogList(list, page);
result = await readTaggedCatalogGridPage(list, page);
} catch (error) {
if (!(error instanceof TmdbError)) throw error;
return (
Expand All @@ -108,7 +106,7 @@ async function ListGrid({
);
}

const lastPage = lastReachablePage(result.paged.totalPages);
const lastPage = result.paged.totalPages;
if (page > lastPage) return <NoSuchPage list={list} />;

return (
Expand Down Expand Up @@ -148,7 +146,7 @@ function ListGridSkeleton() {
return (
<div className="flex flex-col gap-10">
<PosterGrid aria-hidden="true">
{Array.from({ length: PAGE_SIZE }, (_, index) => (
{Array.from({ length: GRID_PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
<li key={index}>
<PosterCardSkeleton />
Expand Down
7 changes: 5 additions & 2 deletions components/library/library-panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -251,12 +251,15 @@ export function LibrarySkeleton() {
);
}

/** Each card's visibility in that row: one more card at each column step. */
/**
* Each card's visibility in that row, matching the grid's 2, 3, 4 and 6
* column steps (spec 0022, AC-6): the last two appear together at `xl`.
*/
const ROW_CARD_VISIBILITY = [
"",
"",
"hidden sm:block",
"hidden md:block",
"hidden lg:block",
"hidden xl:block",
"hidden xl:block",
];
2 changes: 1 addition & 1 deletion components/library/library-section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe("the reads each tab makes (AC-15, AC-16)", () => {
it("redirects a page past the end to the last page", async () => {
getShowLibraryTab.mockResolvedValue(
tab([{ page: "watched", showId: 1, title: TITLE, label: "finished" }], {
total: 21,
total: 25,
}),
);
await expect(
Expand Down
13 changes: 9 additions & 4 deletions components/poster-grid.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {
PosterGrid,
type PosterGridColumns,
} from "@/components/poster-grid";
import { GRID_PAGE_SIZE } from "@/lib/catalog/grid-page";

/**
* covers: spec 0004 AC-9, AC-8; spec 0022 AC-1, AC-3, AC-4, AC-5
* covers: spec 0004 AC-9, AC-8; spec 0022 AC-1, AC-3, AC-4, AC-5, AC-22
*
* The grid's own comment names the failure this file is here to prevent: the
* column counts and their image `sizes` live side by side in
Expand All @@ -35,7 +36,6 @@ const COLUMNS_AT: Record<
> = {
catalog: [
{ minWidth: 1280, columns: 6 },
{ minWidth: 1024, columns: 5 },
{ minWidth: 768, columns: 4 },
{ minWidth: 640, columns: 3 },
{ minWidth: null, columns: 2 },
Expand Down Expand Up @@ -140,14 +140,19 @@ describe("PosterGrid", () => {
"grid-cols-2",
"sm:grid-cols-3",
"md:grid-cols-4",
"lg:grid-cols-5",
"xl:grid-cols-6",
"max-w-[1200px]",
"mx-auto",
"gap-x-4",
"gap-y-6",
);
expect(grid.className).not.toMatch(/2xl:grid-cols-/);
expect(grid.className).not.toMatch(/(?:lg|2xl):grid-cols-/);
});

it("divides the 24 title page at every column step (spec 0022, AC-22)", () => {
for (const { columns } of COLUMNS_AT.catalog) {
expect(GRID_PAGE_SIZE % columns).toBe(0);
}
});

it("keeps the wide preset at today's 2 and 4 columns", () => {
Expand Down
18 changes: 9 additions & 9 deletions components/poster-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ type PosterGridColumns = "catalog" | "wide";
* every one of them.
*/
const COLUMN_CLASSES: Record<PosterGridColumns, string> = {
catalog:
"grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6",
catalog: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6",
wide: "grid-cols-2 md:grid-cols-4",
};

Expand All @@ -27,7 +26,7 @@ const COLUMN_CLASSES: Record<PosterGridColumns, string> = {
*/
const POSTER_GRID_SIZES: Record<PosterGridColumns, string> = {
catalog:
"(min-width: 1280px) 187px, (min-width: 1232px) 228px, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw",
"(min-width: 1280px) 187px, (min-width: 1232px) 288px, (min-width: 768px) 25vw, (min-width: 640px) 33vw, 50vw",
wide: "(min-width: 1232px) 288px, (min-width: 768px) 25vw, 50vw",
};

Expand All @@ -43,12 +42,13 @@ const POSTER_GRID_EAGER: Record<PosterGridColumns, number> = {
/**
* The poster grid, mobile first (spec 0004, AC-9, amended by spec 0022).
*
* `catalog` steps 2, 3 (`sm`), 4 (`md`), 5 (`lg`) and 6 (`xl`) columns, so a
* grid card stays close to the home row card's 180px; a 20 title page leaves a
* short last row at 3, 5 and 6 columns, accepted in spec 0022. `wide` keeps 2
* and 4 for the season grid. The columns, `POSTER_GRID_SIZES` and
* `POSTER_GRID_EAGER` change together; a card in the grid passes the preset's
* sizes.
* `catalog` steps 2, 3 (`sm`), 4 (`md`, and still at `lg`) and 6 (`xl`)
* columns, so a wide screen card stays close to the home row card's 180px.
* Every step divides the 24 title page (`GRID_PAGE_SIZE`), so a full page
* fills every row at every width; there is deliberately no 5 column step
* (spec 0022, AC-1, AC-22). `wide` keeps 2 and 4 for the season grid. The
* columns, `POSTER_GRID_SIZES` and `POSTER_GRID_EAGER` change together; a
* card in the grid passes the preset's sizes.
*
* The 1200px cap keeps a full page a sensible length on a wide screen; past
* the cap the grid is centred on every page, under the centred pagination.
Expand Down
Loading
Loading