diff --git a/app/privacy/page.test.tsx b/app/privacy/page.test.tsx index dd8716d..c91f64d 100644 --- a/app/privacy/page.test.tsx +++ b/app/privacy/page.test.tsx @@ -97,6 +97,18 @@ describe("/privacy", () => { ); }); + /** covers: prompts/tmdb-image-loader.md, AC-6 */ + it("says images load from TMDB, not from the host", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + expect(text).toContain("Your browser loads the images from"); + expect(text).not.toContain("our host delivers the images"); + expect( + PROCESSORS.find((processor) => processor.name === "Vercel")?.purpose, + ).not.toMatch(/image/i); + }); + it("opens external links in a new tab, safely (AC-7)", () => { const { container } = render(); @@ -135,6 +147,18 @@ describe("/privacy", () => { expect(text).toContain("EU Standard Contractual Clauses"); }); + it("states each processor's transfer from the one module", () => { + const { container } = render(); + const text = container.textContent ?? ""; + + for (const processor of PROCESSORS) { + if (processor.transfer) expect(text).toContain(processor.transfer); + } + expect( + PROCESSORS.find((processor) => processor.name === "TMDB")?.transfer, + ).toMatch(/outside the EEA/); + }); + it("promises no account email production can't send (spec 0018, AC-23)", () => { const { container } = render(); diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx index 445c21a..90084d6 100644 --- a/app/privacy/page.tsx +++ b/app/privacy/page.tsx @@ -18,6 +18,7 @@ import { RIGHTS_RESPONSE, SUPABASE_REGION, SUPERVISORY_AUTHORITY, + TMDB_HOME_URL, } from "@/lib/legal/operator"; import { catalogMetadata } from "@/lib/seo/metadata"; @@ -134,9 +135,11 @@ export default function PrivacyPage() {

Catalog details come from{" "} - TMDB, - which receives no personal data from you: our server makes the catalog - requests, and our host delivers the images. + TMDB. Our server + makes the catalog requests, so TMDB never receives your account or + tracking data. Your browser loads the images from TMDB’s image + service, which sees your IP address and browser details, and + TMDB’s own privacy policy applies to them.

@@ -146,11 +149,13 @@ export default function PrivacyPage() { {SUPABASE_REGION.name}, and the app’s server functions in the matching Vercel region.

-

- Supabase and Vercel are United States companies. Where their staff - access your data for support and operations, that access is covered by - the EU Standard Contractual Clauses. -

+ {PROCESSORS.flatMap((processor) => + processor.transfer ? ( +

{processor.transfer}

+ ) : ( + [] + ), + )} diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index e68ba8b..02d7411 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -50,6 +50,7 @@ import { import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { catalogList, listPath } from "@/lib/catalog/lists"; +import { TMDB_HOME_URL } from "@/lib/legal/operator"; import { CardRoundButtonDemo } from "./card-round-button-demo"; @@ -568,9 +569,7 @@ export default function ShowcasePage() {

Body text in the secondary token, with an{" "} - - external link - {" "} + external link{" "} and the contact address, .

diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx index ec9741f..e09c680 100644 --- a/components/layout/site-footer.tsx +++ b/components/layout/site-footer.tsx @@ -2,7 +2,11 @@ import { cn } from "cn"; import Image from "next/image"; import Link from "next/link"; import type * as React from "react"; -import { OPERATOR_GITHUB_URL, OPERATOR_HANDLE } from "@/lib/legal/operator"; +import { + OPERATOR_GITHUB_URL, + OPERATOR_HANDLE, + TMDB_HOME_URL, +} from "@/lib/legal/operator"; import { TMDB_ATTRIBUTION } from "@/lib/tmdb"; import { IconKey } from "./icon-key"; @@ -32,7 +36,7 @@ export function SiteFooter() {
{ ]); }); + it("writes TMDB's site URL only in lib/legal/operator.ts", () => { + expect(filesContaining(TMDB_HOME_URL)).toEqual(["lib/legal/operator.ts"]); + }); + + it("names no processor's country in /privacy itself", () => { + expect(readFileSync("app/privacy/page.tsx", "utf8")).not.toContain( + "United States", + ); + }); + it("writes the JustWatch credit only in lib/legal/operator.ts (spec 0022, AC-12)", () => { expect(filesContaining(JUSTWATCH_ATTRIBUTION)).toEqual([ "lib/legal/operator.ts", diff --git a/lib/legal/operator.ts b/lib/legal/operator.ts index 42a45a2..5aedef4 100644 --- a/lib/legal/operator.ts +++ b/lib/legal/operator.ts @@ -42,7 +42,7 @@ export const MINIMUM_AGE = 16; * whenever the text changes materially. Pages format it in UTC and never read * the clock (spec 0017, AC-7). */ -export const LEGAL_LAST_UPDATED = "2026-10-12"; +export const LEGAL_LAST_UPDATED = "2026-10-16"; /** * Where the production Supabase project stores your data, read from @@ -63,6 +63,15 @@ export const SUPABASE_REGION = { */ export const JUSTWATCH_ATTRIBUTION = "Availability data from JustWatch"; +/** + * TMDB's own site, where the footer logo, the privacy policy and TMDB's + * processor entry link. One home, like `JUSTWATCH_ATTRIBUTION`, so the link + * cannot drift between the places that name TMDB. It lives here rather than in + * `lib/tmdb` because that entry point is server only and the footer test and + * the legal pages render in jsdom. + */ +export const TMDB_HOME_URL = "https://www.themoviedb.org"; + /** Days within which an account deletion request is completed. */ export const DELETION_DAYS = 30; @@ -72,27 +81,48 @@ export const RIGHTS_RESPONSE = "one month"; /** Days within which a complaint about the service is answered. */ export const COMPLAINT_RESPONSE_DAYS = 14; -/** A service provider that processes personal data on the operator's behalf. */ -export type Processor = { name: string; purpose: string; url: string }; +/** + * A service provider that processes personal data on the operator's behalf. + * `transfer`, when set, is what "Transfers outside the EEA" says about it, so + * the company's country lives beside its other facts and not in page text. + */ +export type Processor = { + name: string; + purpose: string; + url: string; + transfer?: string; +}; /** * Only the processors live today. Production sends no email (spec 0018), so * neither Google nor an email provider is listed; both join with the deferred * email delivery and Google sign in work, in the PR that turns them on. * YouTube joined with the title page trailers (spec 0022, AC-19): its player - * loads only after you press Trailer. + * loads only after you press Trailer. TMDB joined when images moved off + * Vercel's optimizer onto TMDB's own CDN (prompts/tmdb-image-loader.md). */ export const PROCESSORS: readonly Processor[] = [ { name: "Supabase", purpose: "Stores your account and tracking data and handles sign in.", url: "https://supabase.com", + transfer: + "Supabase is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.", }, { name: "Vercel", - purpose: - "Hosts the website, delivers catalog images, and keeps request logs.", + purpose: "Hosts the website and keeps request logs.", url: "https://vercel.com", + transfer: + "Vercel is a United States company. Where its staff access your data for support and operations, that access is covered by the EU Standard Contractual Clauses.", + }, + { + name: "TMDB", + purpose: + "Delivers posters, photos and other catalog images straight to your browser, which shares your IP address with it.", + url: TMDB_HOME_URL, + transfer: + "TMDB is a United States company. The images your browser loads from it come from its content delivery network, which may serve them from outside the EEA.", }, { name: "YouTube", diff --git a/lib/tmdb/AGENTS.md b/lib/tmdb/AGENTS.md index fefe133..c1d4266 100644 --- a/lib/tmdb/AGENTS.md +++ b/lib/tmdb/AGENTS.md @@ -1,6 +1,7 @@ # AGENTS.md · lib/tmdb -The server only module every TMDB request goes through. Decided in +The server only module every TMDB request goes through, plus the one browser +file, `image-loader.ts`, that `next.config.ts` hands to `next/image`. Decided in [spec 0002](../../docs/specs/0002-tmdb-integration-module/index.md); scope feature 4. Runtime steps live in that spec's `verify.md`. @@ -8,7 +9,9 @@ feature 4. Runtime steps live in that spec's `verify.md`. - `index.ts` is the only entry point a page may import. It exports the cached reads, `imageUrl`, `isTmdbNotFound`, `TmdbError`, `TMDB_ATTRIBUTION` and the - normalized types. Everything else in this folder is internal. + normalized types. Everything else in this folder is internal, except + `image-loader.ts` (`'use client'`), which only the `loaderFile` setting + imports, and the pure `images.ts` it reads. - `reads.ts` holds every `use cache` wrapper and is the only file that imports `next/cache`. - `client.ts` owns auth, the deadline, the retry budget, the status mapping and @@ -69,6 +72,14 @@ feature 4. Runtime steps live in that spec's `verify.md`. (`resolveOverview` in `normalize.ts`, spec 0006, AC-5). - **Errors carry a `kind`**, never a bare status. Pages branch through `isTmdbNotFound`, not through a hand rolled catch. +- **Images skip Vercel's optimizer.** `image-loader.ts` (`'use client'`, the + `loaderFile` in `next.config.ts`) maps a `next/image` width onto TMDB's own + sizes, never above the size `images.ts` pinned and never `original`, so the + browser loads straight from TMDB's CDN (prompts/tmdb-image-loader.md). A new + image field keeps a size from `TMDB_IMAGE_WIDTHS` in `images.ts`, the one + list the loader and `TmdbImageSize` share. `h632`, another host or a size + off that list passes through unresized, and Next's dev warning ("does not + implement width") names it. - Adding a field the app needs is a three place change: `schemas.ts`, `types.ts`, `normalize.ts`. diff --git a/lib/tmdb/image-loader.test.ts b/lib/tmdb/image-loader.test.ts new file mode 100644 index 0000000..8165e3e --- /dev/null +++ b/lib/tmdb/image-loader.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { TMDB_IMAGE_BASE } from "./constants"; +import tmdbImageLoaderDefault, { tmdbImageLoader } from "./image-loader"; + +/** + * covers: prompts/tmdb-image-loader.md, AC-1 to AC-3 + * + * The loader is what keeps Vercel Image Optimization at zero transformations, + * so a wrong rung is either a broken TMDB URL (a 400 for `w640`) or a heavier + * image than the field size allows. Both are invisible until someone looks. + */ +const at = (size: string) => `${TMDB_IMAGE_BASE}/${size}/abc123.jpg`; +/** The source's own size comes back marked, so Next sees the width honoured. */ +const same = (size: string) => `${at(size)}#same-size`; + +describe("tmdbImageLoader", () => { + it("is the module's default export, which loaderFile needs", () => { + expect(tmdbImageLoaderDefault).toBe(tmdbImageLoader); + }); + + it.each([ + [32, "w92"], + [92, "w92"], + [93, "w154"], + [200, "w300"], + [342, "w342"], + ])("serves a w500 poster at width %i from %s (AC-1)", (width, size) => { + expect(tmdbImageLoader({ src: at("w500"), width, quality: 75 })).toBe( + at(size), + ); + }); + + it.each([384, 3840])( + "serves a w500 poster at width %i from its own size, marked so Next does not warn", + (width) => { + expect(tmdbImageLoader({ src: at("w500"), width })).toBe(same("w500")); + }, + ); + + it.each([[640, "w780"]])( + "serves a w1280 backdrop at width %i from %s (AC-2)", + (width, size) => { + expect(tmdbImageLoader({ src: at("w1280"), width })).toBe(at(size)); + }, + ); + + it("serves a w1280 backdrop from its own size above w780 (AC-2)", () => { + expect(tmdbImageLoader({ src: at("w1280"), width: 828 })).toBe( + same("w1280"), + ); + }); + + it("never asks for more than the pinned profile size", () => { + expect(tmdbImageLoader({ src: at("w185"), width: 384 })).toBe(same("w185")); + }); + + it("caps an original source at w1280 and still sizes it down (AC-3)", () => { + expect(tmdbImageLoader({ src: at("original"), width: 3840 })).toBe( + at("w1280"), + ); + expect(tmdbImageLoader({ src: at("original"), width: 128 })).toBe( + at("w154"), + ); + }); + + it.each([ + at("h632"), + at("w45"), + `${TMDB_IMAGE_BASE}/abc123.jpg`, + "https://example.com/t/p/w500/abc123.jpg", + "/tmdb-logo.svg", + ])( + "returns %s unchanged, leaving Next's width warning to flag it (AC-3)", + (src) => { + expect(tmdbImageLoader({ src, width: 200 })).toBe(src); + }, + ); +}); diff --git a/lib/tmdb/image-loader.ts b/lib/tmdb/image-loader.ts new file mode 100644 index 0000000..94bf55d --- /dev/null +++ b/lib/tmdb/image-loader.ts @@ -0,0 +1,55 @@ +"use client"; + +import { TMDB_IMAGE_BASE } from "./constants"; +import { splitImageUrl, TMDB_IMAGE_WIDTHS } from "./images"; + +/** What `original` is capped to, so a full size source never ships as is. */ +const ORIGINAL_CEILING = 1280; + +/** + * Appended when the fitting size is the source's own. Next warns in + * development that a loader "does not implement width" whenever it returns + * `src` unchanged, which every image at its pinned size would. A fragment never + * reaches TMDB, so the request and its CDN cache entry are the same file. + */ +const SAME_SIZE_FRAGMENT = "#same-size"; + +type LoaderParams = { src: string; width: number; quality?: number }; + +/** + * Maps a `next/image` width request onto TMDB's own pre-sized images, so the + * browser fetches straight from TMDB's CDN and Vercel Image Optimization runs + * no transformations (prompts/tmdb-image-loader.md). TMDB already negotiates + * WebP from the `Accept` header, so nothing is lost by skipping the optimizer. + * + * The size `lib/tmdb/images.ts` pinned is the ceiling: a `w500` poster is never + * upgraded on a dense screen, so no page gets heavier than its field size allows. + * `quality` is ignored because TMDB has no such parameter. Any URL that is not + * a TMDB size path on the ladder comes back unchanged, so the loader never + * builds a URL for a host it was not handed. Next's development warning then + * names that image, which is the signal to pin it to a ladder size. + */ +export function tmdbImageLoader({ src, width }: LoaderParams): string { + const parts = splitImageUrl(src); + if (!parts) return src; + const ceiling = sizeCeiling(parts.size); + if (ceiling === null) return src; + + // `original` is left off the ladder because it can be several megabytes. + const fit = TMDB_IMAGE_WIDTHS.find((rung) => rung >= width) ?? ceiling; + const url = `${TMDB_IMAGE_BASE}/w${Math.min(fit, ceiling)}${parts.path}`; + return url === src ? `${url}${SAME_SIZE_FRAGMENT}` : url; +} + +/** The pixel width a source size allows, or null when it is not on the ladder. */ +function sizeCeiling(size: string): number | null { + if (size === "original") return ORIGINAL_CEILING; + const match = /^w(\d+)$/.exec(size); + if (!match) return null; + const value = Number(match[1]); + return (TMDB_IMAGE_WIDTHS as readonly number[]).includes(value) + ? value + : null; +} + +export default tmdbImageLoader; diff --git a/lib/tmdb/images.ts b/lib/tmdb/images.ts index b77e945..8d44c91 100644 --- a/lib/tmdb/images.ts +++ b/lib/tmdb/images.ts @@ -1,22 +1,47 @@ import { TMDB_IMAGE_BASE } from "./constants"; /** - * The TMDB image widths this app is allowed to request. + * The TMDB widths this app may request, smallest first. Each answers 200 for + * posters, backdrops, profiles and stills alike (checked live on 2026-10-16). + * + * One list for both the pinned sizes below and the `next/image` loader's + * ladder (`image-loader.ts`), so a width the loader can pick is always one the + * type allows, and adding a size is one edit. + */ +export const TMDB_IMAGE_WIDTHS = [ + 92, 154, 185, 300, 342, 500, 780, 1280, +] as const; + +/** + * The TMDB image sizes this app is allowed to request. * * A closed union rather than a free string: a typo in a size produces a URL * TMDB answers with a 404 image, which is invisible until someone looks at the * page. `original` is included for the rare full size case. */ export type TmdbImageSize = - | "w92" - | "w154" - | "w185" - | "w300" - | "w500" - | "w780" - | "w1280" + | `w${(typeof TMDB_IMAGE_WIDTHS)[number]}` | "original"; +/** A TMDB image URL split at its size segment. */ +export type TmdbImageParts = { size: string; path: string }; + +/** + * Splits an absolute TMDB image URL into its size segment and file path, or + * returns null for a URL this module did not build. The one parser both + * `resizeImageUrl` and the `next/image` loader use, so they agree on what a + * TMDB size path is. The size comes back as a plain string because a URL from + * TMDB itself can carry sizes the app never requests, such as `h632`. + */ +export function splitImageUrl(url: string): TmdbImageParts | null { + const prefix = `${TMDB_IMAGE_BASE}/`; + if (!url.startsWith(prefix)) return null; + const rest = url.slice(prefix.length); + const slash = rest.indexOf("/"); + if (slash === -1) return null; + return { size: rest.slice(0, slash), path: rest.slice(slash) }; +} + /** * The size each normalized field is built with (spec 0002, AC-27). * @@ -72,10 +97,6 @@ export function resizeImageUrl( size: TmdbImageSize, ): string | null { if (!url) return null; - const prefix = `${TMDB_IMAGE_BASE}/`; - if (!url.startsWith(prefix)) return url; - const rest = url.slice(prefix.length); - const slash = rest.indexOf("/"); - if (slash === -1) return url; - return `${prefix}${size}${rest.slice(slash)}`; + const parts = splitImageUrl(url); + return parts ? `${TMDB_IMAGE_BASE}/${size}${parts.path}` : url; } diff --git a/next.config.test.ts b/next.config.test.ts index a8c0e0c..282e540 100644 --- a/next.config.test.ts +++ b/next.config.test.ts @@ -84,6 +84,12 @@ describe("next.config", () => { ]); }); + /** covers: prompts/tmdb-image-loader.md, AC-4 */ + it("loads images through the TMDB loader, never Vercel's optimizer", () => { + expect(nextConfig.images?.loader).toBe("custom"); + expect(nextConfig.images?.loaderFile).toBe("./lib/tmdb/image-loader.ts"); + }); + it("keeps Cache Components on, which every route is written against", () => { expect(nextConfig.cacheComponents).toBe(true); }); diff --git a/next.config.ts b/next.config.ts index de9abcd..c65d5f7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -60,7 +60,15 @@ const nextConfig: NextConfig = { : []), ]; }, + /** + * A custom loader points every `next/image` at TMDB's own pre-sized files, so + * the browser loads from TMDB's CDN and Vercel's optimizer, whose Hobby quota + * a catalog this size outgrows, never runs (prompts/tmdb-image-loader.md). + * `remotePatterns` stays as the record of the one image host. + */ images: { + loader: "custom", + loaderFile: "./lib/tmdb/image-loader.ts", remotePatterns: [ { protocol: "https", diff --git a/prompts/tmdb-image-loader.md b/prompts/tmdb-image-loader.md new file mode 100644 index 0000000..a43e441 --- /dev/null +++ b/prompts/tmdb-image-loader.md @@ -0,0 +1,157 @@ +# TMDB image loader + +## Goal + +Stop Vercel Image Optimization from running on every TMDB image. The Hobby +quota (5,000 transformations a month) is at 5.1K. A transformation is one unique +source URL × width × quality × format, so a catalog with thousands of posters, +backdrops, profiles and stills, several `srcset` widths each, and crawlers that +walk the sitemap will pass that quota at any traffic level. + +TMDB's CDN already serves every image pre-sized (`w92` … `w1280`) and returns +WebP when the browser's `Accept` header allows it, so Vercel's resize is +redundant. A custom `next/image` loader sends the browser straight to the +TMDB size that fits, so Vercel does no transformations at all. + +## Inspected + +- `next.config.ts`: `images.remotePatterns` for `image.tmdb.org/t/p/**` and + nothing else, so the default loader with default `deviceSizes`/`imageSizes` + is in use. +- `lib/tmdb/images.ts`: `TmdbImageSize`, the pinned per field sizes + (`POSTER_SIZE` w500, `BACKDROP_SIZE` w1280, `PROFILE_SIZE` w185, + `STILL_SIZE` w300, `COMPANY_LOGO_SIZE` w154, `PROVIDER_LOGO_SIZE` w92), + `imageUrl`, `resizeImageUrl`. +- Every `next/image` user: `poster-card`, `list-cards`, `catalog-list-cards`, + `carousel-row`, `cast-row`, `title-extras`, `detail-hero`, `season-header`, + `season-grid`, `episode-row`, `quick-search-row`, `where-to-watch`, + `title-details`, the library cards, `/showcase`. All pass TMDB URLs built by + `lib/tmdb`. `site-footer` renders the local TMDB logo SVG `unoptimized`, so + the loader is never called for it. +- `node_modules/next/dist/docs/.../images.md`: `loader: "custom"` + + `loaderFile`, a `'use client'` module with a default export + `({ src, width, quality }) => string`. +- `node_modules/next/dist/shared/lib/get-img-props.js`: in development Next + warns when the loader's URL equals `src`, i.e. it "does not implement width". +- Live TMDB check (2026-10-16): `w45, w92, w154, w185, w300, w342, w500, w780, + w1280, h632, original` all return 200 for a poster, a backdrop and a profile + path; `w640` returns 400. Responses carry `cache-control: public, + max-age=31919000` and come back as `image/webp` for `Accept: image/webp`. +- `lib/legal/operator.ts` and `app/privacy/page.tsx`: the policy says TMDB + "receives no personal data from you … our host delivers the images", and + `PROCESSORS` gives Vercel the job "delivers catalog images". + +## Skills used + +None of the installed skills cover image loaders. The Next.js docs shipped in +`node_modules` are the reference. + +## Decisions + +1. **Images load directly from `image.tmdb.org`** (your choice, 2026-10-16), + not through a same-origin Vercel rewrite, which would only swap image + transformations for edge requests and origin transfer. +2. **Width ladder:** `w92, w154, w185, w300, w342, w500, w780, w1280`, kept as + `TMDB_IMAGE_WIDTHS` in `images.ts`, the one list `TmdbImageSize` is built + from too (amended after review). The loader picks the smallest rung ≥ the requested width. `original` is never + chosen, because it can be several MB. +3. **The size `lib/tmdb` pinned stays the ceiling.** A `w500` poster is never + upgraded to `w780` on a 3× screen, so no image becomes heavier than it is + today and `images.ts`' rule that a page can't quietly request larger images + still holds. Wide `srcset` entries collapse to the same URL, and the browser + dedupes them. +4. **Anything else passes through unchanged:** a URL that isn't + `https://image.tmdb.org/t/p//`, or that has an unknown size, + comes back untouched. The loader never builds a URL for another host. + Next's dev warning ("does not implement width") then names that image, the + signal to pin it to a ladder size; no caller does so today. + `remotePatterns` stays in the config as documentation of the one allowed + host. +5. **`quality` is ignored**, because TMDB has no quality parameter. +5a. **A URL already at its own size gets a `#same-size` fragment** (found while + verifying): Next's dev check warns "does not implement width" whenever the + loader returns `src` unchanged, which every image at its pinned size does. + A fragment never reaches TMDB, so the request and its cache entry are + unchanged. +6. **Privacy text follows the code** (AGENTS.md: legal pages describe only what + the code does): + - Vercel's `PROCESSORS` purpose becomes "Hosts the website and keeps request + logs." + - TMDB joins `PROCESSORS` ("Delivers posters, photos and other catalog + images straight to your browser, which shares your IP address with it.", + `https://www.themoviedb.org`), following the YouTube precedent. + - The paragraph under "Who processes your data" becomes: our server makes + the catalog requests, so TMDB receives no account or tracking data; your + browser loads the images from TMDB's image service, which sees your IP + address and browser details, and TMDB's privacy policy applies. + - "Transfers outside the EEA" adds that TMDB is a United States company. + - `LEGAL_LAST_UPDATED` moves to `2026-10-16`. + - The wording is the operator's legal text. Review it in the PR. + +## Expected files + +- `lib/tmdb/image-loader.ts` (new): `'use client'`, pure, the default export + loader plus an exported `tmdbImageLoader` for tests. Imports only + `TMDB_IMAGE_BASE` and the size type, nothing `server-only`. +- `lib/tmdb/image-loader.test.ts` (new). +- `next.config.ts`: `images.loader: "custom"`, `loaderFile: + "./lib/tmdb/image-loader.ts"`, and its JSDoc saying why. +- `next.config.test.ts`: pin `loader: "custom"` and the loader file. +- `lib/legal/operator.ts`, `app/privacy/page.tsx`, `app/privacy/page.test.tsx` + where needed. +- `lib/tmdb/AGENTS.md`: one line on the loader and the ceiling rule. + +## Requirements + +- R1. No `next/image` in the app requests `/_next/image`. +- R2. Every TMDB `srcset` candidate is a valid TMDB size path no larger than the + size `lib/tmdb` chose. +- R3. Non TMDB or unrecognised URLs pass through unchanged. +- R4. No visual change: same layout, `sizes`, placeholders and missing image + states. +- R5. The privacy page and `PROCESSORS` describe the new image path. + +## Security and privacy + +- The loader runs in the browser, but it only rewrites the size segment of + public CDN URLs. No token or env value is involved, and + `security-boundary.test.ts` still holds. +- Visitors' IP addresses now reach TMDB's image CDN. The privacy text changes + in the same PR, as AGENTS.md requires. +- There is no open redirect or host widening: the loader never builds a URL for + a host it was not given. + +## Acceptance criteria + +- AC-1. `tmdbImageLoader({ src: ".../w500/x.jpg", width: 200 })` returns + `.../w300/x.jpg`. Width 92 returns `w92`, width 342 returns `w342`, and width + 3840 returns `.../w500/x.jpg#same-size` (the ceiling, marked per 5a). +- AC-2. A `w1280` backdrop at width 828 returns `.../w1280/x.jpg#same-size`. + At width 640 it returns `w780`. +- AC-3. `original`, `h632` or unknown sources: the ceiling is treated as + `w1280` for `original`, and anything not on the ladder or not under the TMDB + base is returned unchanged. +- AC-4. `next.config` declares `loader: "custom"` and `loaderFile`. +- AC-5. A production build and a page load show `` pointing at + `image.tmdb.org`, with no `/_next/image` requests and no loader width + warning in dev. +- AC-6. `/privacy` lists TMDB, Vercel's purpose no longer mentions images, and + `Last updated` reads 16 October 2026. + +## Automated checks + +`pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build`. + +## Manual test steps + +1. `pnpm dev`, then open `/shows`. In DevTools → Network → Img, every poster + request goes to `image.tmdb.org/t/p/w…/…`, and none to `/_next/image`. +2. Inspect a poster ``: `srcset` lists TMDB size URLs, none larger than + `w500`. +3. Open a show page: the backdrop loads from `w780`/`w1280`, cast photos from + `w92`–`w185`, and the season episode stills from up to `w300`. +4. Toggle a mobile viewport and reload: smaller rungs are fetched. +5. Check that a title with no poster still shows the placeholder. +6. Open `/privacy`: TMDB is listed and the date is updated. +7. After deploy, Vercel → Usage → Image Optimization: the transformation count + stops growing.