diff --git a/AGENTS.md b/AGENTS.md index 00cb143..fe01a1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,7 +296,7 @@ Tracer Bullet: prove the whole pipe works with one thin real thread, then thicke - `pnpm-workspace.yaml` exists only to pin `allowBuilds`. This is a single package repo, not a monorepo. - `cacheComponents: true` is on in `next.config.ts`. Every route must be prerenderable or opt out with `export const instant = false`, and every cached read calls `cacheLife` inside its own `use cache` scope. A rejection thrown inside a cached scope loses its class and its fields, so return a plain result and rebuild the error outside the scope. - The TMDB token is server only. Only `lib/tmdb/env.ts` may read `TMDB_READ_ACCESS_TOKEN`, and `security-boundary.test.ts` fails if any other file names it or gives it a `NEXT_PUBLIC_` prefix, the same rule the Supabase service role key carries. -- No page writes when it loads (spec 0020, AC-21): the library pages and `/shows/{id}` only read. A library tab classifies every tracked show (or every planned unwatched movie) per request, at most 500 per media type, from one cached TMDB details read per title (`lib/tracking/library-lists.ts`, the pure `lib/tv/library-page.ts` and `lib/catalog/movie-page.ts`). Keep it that way unless a spec says otherwise. +- No page writes when it loads (spec 0020, AC-21): the library pages and `/shows/{id}` only read. A library tab classifies every tracked show (or every planned unwatched movie) per request, at most 500 per media type, from one cached TMDB details read per title (`lib/tracking/library-lists.ts`, the pure `lib/tv/library-page.ts` and `lib/catalog/movie-page.ts`). The catalog grids on `/shows`, `/movies` and `/search` place their own titles with the same classifiers, so a card always matches its library page: one `cache()`d read per grid, and TMDB details only for the grid's tracked shows (`lib/tracking/show-grid-status.ts`, `getMovieGridStates`; prompts/catalog-card-status.md). Keep it that way unless a spec says otherwise. - `user_show_state` is `user_id`, `show_id`, `tracked_at` and the timestamps, nothing more: the spec 0020 contract migration (`20261010120000_show_tracking_contract.sql`) dropped the legacy status columns, the `tv_status`, `status_source` and `show_hold` enums, the old views and every status and hold function. `user_tracked_shows` is the one view. A show page control is a Plan to watch / Tracking toggle. - The navbar's SHOWS | MOVIES tabs filter every page (scope feature 22, [prompts/media-tabs-everywhere.md](prompts/media-tabs-everywhere.md)). `/watchlist`, `/upcoming`, `/watched` and `/search` take their media type from `?type=tv|movie`. A bare URL means `tv`, and an invalid value shows a "That page doesn't exist" panel, never a redirect. Each tab has its own reads, count and pages. The parsing and URL building live only in `lib/catalog/media-type.ts`, so use `typedHref` for any link to these pages and always write `type`. The client shell reads `useSearchParams` only on those four paths, inside its own Suspense boundary; reading it anywhere else takes `/shows`, `/movies` and the title pages out of their prerendered shells. - TMDB images render through `next/image`; `image.tmdb.org` is the one allowed remote pattern. diff --git a/app/globals.css b/app/globals.css index 9998bdc..e360590 100644 --- a/app/globals.css +++ b/app/globals.css @@ -60,6 +60,12 @@ --color-score-personal: #50c6e5; --color-status-planned: #29cea3; --color-status-planned-mark: #06251d; + /* + * Text on the cyan selected tab (`glass-selected-personal`). Near white on + * the cyan measures under 3:1, so the label goes dark, as the Planned check + * does on its green: 6.3:1 at the gradient's darkest stop. + */ + --color-text-on-personal: #04161c; /* * The opaque plates painted under every glass surface. Glass alone over a @@ -184,6 +190,42 @@ var(--glass-rim, none) border-box; } +/* + * The selected navbar tab (prompts/grid-tabs-footer-legend.md): the same + * layer stack as `glass-selected`, its gradient tinted from the personal + * score cyan so the lit tab reads in the colour of the user's own star. The + * stops stay translucent, so the plate and rim still show through as glass; + * at 80% the darkest stop over the control plate keeps + * `text-on-personal` at 6.3:1. + */ +@utility glass-selected-personal { + border: 1.5px solid transparent; + background: + linear-gradient( + 172deg, + color-mix( + in srgb, + color-mix(in srgb, var(--color-score-personal) 70%, white) 95%, + transparent + ) + 0%, + color-mix(in srgb, var(--color-score-personal) 80%, transparent) 52%, + color-mix( + in srgb, + color-mix(in srgb, var(--color-score-personal) 85%, white) 90%, + transparent + ) + 100% + ) + padding-box, + linear-gradient( + var(--glass-plate, transparent) 0%, + var(--glass-plate, transparent) 100% + ) + padding-box, + var(--glass-rim, none) border-box; +} + /* * A plated surface with the rim but no glass gradient: the missing poster * fallback and the loading skeletons, which are the surfaces that should read diff --git a/app/movies/page.tsx b/app/movies/page.tsx index a6c2bc2..b57fb4a 100644 --- a/app/movies/page.tsx +++ b/app/movies/page.tsx @@ -6,9 +6,12 @@ import { PosterCard } from "@/components/poster-card"; import { PosterGrid } from "@/components/poster-grid"; import { TmdbRatingBadge } from "@/components/rating-badges"; import { RetryLink } from "@/components/retry-link"; -import { PosterCardSkeleton } from "@/components/skeleton"; +import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; -import { CardBookmark } from "@/components/tracking/card-bookmark"; +import { + MovieCardBadge, + MovieCardControls, +} from "@/components/tracking/movie-card-slots"; import { ButtonLink } from "@/components/ui/button"; import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; import { landingPageMetadata } from "@/lib/landing-metadata"; @@ -18,10 +21,10 @@ import { discoverMovies, TmdbError } from "@/lib/tmdb"; const PAGE_SIZE = 20; /** - * Cards whose posters load eagerly: one full row at the widest grid (6 - * columns at `xl`), so the first row is never lazy at any width (AC-1). + * Cards whose posters load eagerly: one full row at the widest grid (4 + * columns from `md`), so the first row is never lazy at any width (AC-1). */ -const EAGER_POSTERS = 6; +const EAGER_POSTERS = 4; /** One URL per page: page 1 is the bare path. */ function pageHref(page: number): string { @@ -57,8 +60,10 @@ export async function generateMetadata({ * The heading is static and prerendered; the grid needs the `page` search * parameter, so it streams inside the Suspense boundary behind a grid of * skeleton cards at the same footprint (AC-11). No session is read here - * (AC-13); each card's bookmark from spec 0007 reads it inside its own - * Suspense boundary in `components/tracking/`, one query for the whole grid. + * (AC-13); each card's badge and controls read it inside their own Suspense + * boundaries in `components/tracking/movie-card-slots.tsx`, one query for the + * whole grid, so a card shows the movie as its library page does + * (prompts/catalog-card-status.md). */ export default function MoviesPage({ searchParams }: PageProps<"/movies">) { return ( @@ -118,12 +123,24 @@ async function PopularMovies({ title={movie.title} posterUrl={movie.posterUrl} href={`/movies/${movie.id}`} - badge={} + badge={ + } + > + + + } controls={ - @@ -160,13 +177,16 @@ function NoSuchPage() { function PopularMoviesSkeleton() { return ( - +
+ + +
); } diff --git a/app/movies/request-scope.test.ts b/app/movies/request-scope.test.ts index 421164f..4d8d06c 100644 --- a/app/movies/request-scope.test.ts +++ b/app/movies/request-scope.test.ts @@ -151,10 +151,12 @@ describe("search reads no request scoped value (spec 0010, AC-22)", () => { ); }); - it("keeps the movie bookmark behind its own Suspense boundary (AC-16)", () => { - expect( - readFileSync("components/search/search-results.tsx", "utf8"), - ).toMatch(/\s* { + const source = readFileSync("components/search/search-results.tsx", "utf8"); + expect(source).toMatch(/\s*\}\s*>\s* { @@ -291,8 +293,10 @@ describe("private tracking state never enters a cache scope (spec 0007, AC-19; s expect(readFileSync("app/movies/[id]/page.tsx", "utf8")).toMatch( /\s*\s*\s*\}\s*>\s*\s*\s*\s*\s*\s*\}\s*>\s*\s*\s*\s*\s* Selected +
+ + SELECTED TAB + + + TAB + +
@@ -222,13 +231,20 @@ export default function ShowcasePage() { A TMDB badge with no value renders nothing; a personal score with no value renders “Not rated”. Plan, Planned and the watched marks are the movie tracking marks from spec 0007, drawn from the - legend’s own paths. Next episode is the Watchlist show - card’s pill, and the dated and Date TBA pills sit on Upcoming - cards (spec 0020); below sm a date that carries its year - drops the calendar glyph. + original legend’s paths and listed in the footer’s icon + key. Next episode is the Watchlist show card’s pill, and the + dated and Date TBA pills sit on Upcoming cards (spec 0020); below{" "} + sm a date that carries its year drops the calendar glyph.

+
+ +
+
diff --git a/app/shows/page.tsx b/app/shows/page.tsx index 33e0eec..b63dfab 100644 --- a/app/shows/page.tsx +++ b/app/shows/page.tsx @@ -6,9 +6,13 @@ import { PosterCard } from "@/components/poster-card"; import { PosterGrid } from "@/components/poster-grid"; import { TmdbRatingBadge } from "@/components/rating-badges"; import { RetryLink } from "@/components/retry-link"; -import { PosterCardSkeleton } from "@/components/skeleton"; +import { PaginationSkeleton, PosterCardSkeleton } from "@/components/skeleton"; import { StatePanel } from "@/components/state-panel"; -import { ShowCardBookmark } from "@/components/tracking/show-card-bookmark"; +import { + ShowCardBadge, + ShowCardControls, + ShowCardMeta, +} from "@/components/tracking/show-card-slots"; import { ButtonLink } from "@/components/ui/button"; import { lastReachablePage, parsePageParam } from "@/lib/catalog/pages"; import { landingPageMetadata } from "@/lib/landing-metadata"; @@ -18,10 +22,10 @@ import { discoverTvShows, TmdbError } from "@/lib/tmdb"; const PAGE_SIZE = 20; /** - * Cards whose posters load eagerly: one full row at the widest grid (6 - * columns at `xl`), so the first row is never lazy at any width (spec 0006, AC-1; spec 0009, AC-1). + * Cards whose posters load eagerly: one full row at the widest grid (4 + * columns from `md`), so the first row is never lazy at any width (spec 0006, AC-1; spec 0009, AC-1). */ -const EAGER_POSTERS = 6; +const EAGER_POSTERS = 4; /** One URL per page: page 1 is the bare path. */ function pageHref(page: number): string { @@ -119,10 +123,29 @@ async function PopularShows({ title={show.name} posterUrl={show.posterUrl} href={`/shows/${show.id}`} - badge={} + badge={ + } + > + + + } + meta={ + + + + } controls={ -