diff --git a/app/movies/popular/page.test.ts b/app/movies/popular/page.test.ts index 639f872..a41e4a3 100644 --- a/app/movies/popular/page.test.ts +++ b/app/movies/popular/page.test.ts @@ -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"; @@ -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(); @@ -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"); @@ -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, diff --git a/app/shows/top-rated/page.test.ts b/app/shows/top-rated/page.test.ts index 3ac4433..f25a04e 100644 --- a/app/shows/top-rated/page.test.ts +++ b/app/shows/top-rated/page.test.ts @@ -27,6 +27,14 @@ function metadataFor(page?: string) { } as Parameters[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(() => { @@ -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(); @@ -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"); @@ -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"); diff --git a/components/AGENTS.md b/components/AGENTS.md index 9455b8a..3dc4298 100644 --- a/components/AGENTS.md +++ b/components/AGENTS.md @@ -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 `` 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 diff --git a/components/catalog/catalog-list-page.test.tsx b/components/catalog/catalog-list-page.test.tsx index 58a2bed..ac44248 100644 --- a/components/catalog/catalog-list-page.test.tsx +++ b/components/catalog/catalog-list-page.test.tsx @@ -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: { @@ -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(); @@ -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" }); @@ -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(); @@ -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(); }); }); diff --git a/components/catalog/catalog-list-page.tsx b/components/catalog/catalog-list-page.tsx index e15992e..9ee7700 100644 --- a/components/catalog/catalog-list-page.tsx +++ b/components/catalog/catalog-list-page.tsx @@ -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>; @@ -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 ( @@ -108,7 +106,7 @@ async function ListGrid({ ); } - const lastPage = lastReachablePage(result.paged.totalPages); + const lastPage = result.paged.totalPages; if (page > lastPage) return ; return ( @@ -148,7 +146,7 @@ function ListGridSkeleton() { return (