Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6cfb344
docs(prompts): plan the four column grid, cyan tabs, footer icon key …
kris1027 Oct 11, 2026
6d1bd33
feat(ui): regrid posters to at most four columns, centred
kris1027 Oct 11, 2026
a573649
feat(layout): light the selected tab in the personal score cyan
kris1027 Oct 11, 2026
aadc219
feat(layout): move the badge legend to a footer icon key on every route
kris1027 Oct 11, 2026
3b85b2b
fix(ui): make every skeleton match the footprint of what replaces it
kris1027 Oct 11, 2026
4360fbc
feat(layout): pair Mark watched with Watched in the icon key
kris1027 Oct 11, 2026
2ae3bbb
docs(prompts): plan real library status on catalog cards
kris1027 Oct 11, 2026
03f960e
refactor(library): share the release and upcoming show pills
kris1027 Oct 11, 2026
194a501
feat(search): carry the movie release date on search results
kris1027 Oct 11, 2026
0a27029
refactor(catalog): place a movie by a watched flag, not a timestamp
kris1027 Oct 11, 2026
68fc631
feat(tracking): add the round watched mark for poster cards
kris1027 Oct 11, 2026
08d7d9f
feat(catalog): show a movie's library status on its grid card
kris1027 Oct 11, 2026
c911ebe
feat(tracking): place a grid's tracked shows on their library pages
kris1027 Oct 11, 2026
1ec0c05
feat(catalog): show a tracked show's library status on its grid card
kris1027 Oct 11, 2026
3004216
refactor(format): move the release date pill formatting into lib/format
kris1027 Oct 11, 2026
39d85b1
refactor(catalog): type a movie card's state as MovieTrackingState
kris1027 Oct 11, 2026
e75cb10
fix(tracking): log a grid's TMDB failure under its own event
kris1027 Oct 11, 2026
4d9aca6
refactor(tracking): share the catalog card badge rule between movies …
kris1027 Oct 11, 2026
b5d2341
refactor(search): split a result card by media type instead of branch…
kris1027 Oct 11, 2026
7458060
refactor(ui): name the skeleton row constants for what they hold
kris1027 Oct 11, 2026
200265b
docs(layout): rewrap the footer's JSDoc
kris1027 Oct 11, 2026
4b74a15
feat(showcase): render the footer icon key
kris1027 Oct 11, 2026
f039e4b
test(tracking): move the movie card slot cases into their own file
kris1027 Oct 11, 2026
9dcfec2
docs(prompts): record where the shipped grid and card status differ f…
kris1027 Oct 11, 2026
315ffed
docs(agents): note that the catalog grids place their titles too
kris1027 Oct 11, 2026
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
42 changes: 42 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 37 additions & 17 deletions app/movies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -118,12 +123,24 @@ async function PopularMovies({
title={movie.title}
posterUrl={movie.posterUrl}
href={`/movies/${movie.id}`}
badge={<TmdbRatingBadge value={movie.tmdbRating} />}
badge={
<Suspense
fallback={<TmdbRatingBadge value={movie.tmdbRating} />}
>
<MovieCardBadge
movieId={movie.id}
releaseDate={movie.releaseDate}
tmdbRating={movie.tmdbRating}
gridMovieIds={gridMovieIds}
/>
</Suspense>
}
controls={
<Suspense fallback={null}>
<CardBookmark
<MovieCardControls
movieId={movie.id}
title={movie.title}
releaseDate={movie.releaseDate}
gridMovieIds={gridMovieIds}
returnPath={pageHref(page)}
/>
Expand Down Expand Up @@ -160,13 +177,16 @@ function NoSuchPage() {

function PopularMoviesSkeleton() {
return (
<PosterGrid aria-hidden="true">
{Array.from({ length: PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
<li key={index}>
<PosterCardSkeleton />
</li>
))}
</PosterGrid>
<div className="flex flex-col gap-10">
<PosterGrid aria-hidden="true">
{Array.from({ length: PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
<li key={index}>
<PosterCardSkeleton />
</li>
))}
</PosterGrid>
<PaginationSkeleton />
</div>
);
}
29 changes: 18 additions & 11 deletions app/movies/request-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<Suspense fallback=\{null\}>\s*<CardBookmark/);
it("keeps the movie card slots behind their own Suspense boundaries (AC-16)", () => {
const source = readFileSync("components/search/search-results.tsx", "utf8");
expect(source).toMatch(/<Suspense fallback=\{null\}>\s*<MovieCardControls/);
expect(source).toMatch(
/<Suspense\s+fallback=\{<TmdbRatingBadge[^}]*\} \/>\}\s*>\s*<MovieCardBadge/,
);
});

it("reads nothing from the request but its query in the Route Handler (AC-20)", () => {
Expand Down Expand Up @@ -291,21 +293,26 @@ describe("private tracking state never enters a cache scope (spec 0007, AC-19; s
expect(readFileSync("app/movies/[id]/page.tsx", "utf8")).toMatch(
/<Suspense fallback=\{null\}>\s*<MovieTrackingSlot/,
);
expect(readFileSync("app/movies/page.tsx", "utf8")).toMatch(
/<Suspense fallback=\{null\}>\s*<CardBookmark/,
const movies = readFileSync("app/movies/page.tsx", "utf8");
expect(movies).toMatch(/<Suspense fallback=\{null\}>\s*<MovieCardControls/);
expect(movies).toMatch(
/<Suspense\s+fallback=\{<TmdbRatingBadge[^}]*\} \/>\}\s*>\s*<MovieCardBadge/,
);
});

it("keeps the show tracking, progress and TV bookmarks each behind their own boundary (spec 0013, AC-11, AC-22; spec 0020, AC-2, AC-6)", () => {
const show = readFileSync("app/shows/[id]/page.tsx", "utf8");
expect(show).toMatch(/<Suspense fallback=\{null\}>\s*<ShowTrackingSlot/);
expect(show).toMatch(/<Suspense fallback=\{null\}>\s*<ShowProgressSlot/);
expect(readFileSync("app/shows/page.tsx", "utf8")).toMatch(
/<Suspense fallback=\{null\}>\s*<ShowCardBookmark/,
const shows = readFileSync("app/shows/page.tsx", "utf8");
expect(shows).toMatch(/<Suspense fallback=\{null\}>\s*<ShowCardControls/);
expect(shows).toMatch(/<Suspense fallback=\{null\}>\s*<ShowCardMeta/);
expect(shows).toMatch(
/<Suspense\s+fallback=\{<TmdbRatingBadge[^}]*\} \/>\}\s*>\s*<ShowCardBadge/,
);
expect(
readFileSync("components/search/search-results.tsx", "utf8"),
).toMatch(/<Suspense fallback=\{null\}>\s*<ShowCardBookmark/);
const search = readFileSync("components/search/search-results.tsx", "utf8");
expect(search).toMatch(/<Suspense fallback=\{null\}>\s*<ShowCardControls/);
expect(search).toMatch(/<Suspense fallback=\{year\}>\s*<ShowCardMeta/);
expect(
readFileSync("components/library/watchlist-show-card.tsx", "utf8"),
).toMatch(/<Suspense fallback=\{null\}>\s*<NextEpisodeName/);
Expand Down
24 changes: 20 additions & 4 deletions app/showcase/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type * as React from "react";
import { GlassPill } from "@/components/glass-pill";
import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
import { MobileMenuSheet } from "@/components/layout/mobile-menu-sheet";
import {
Expand Down Expand Up @@ -161,6 +162,14 @@ export default function ShowcasePage() {
<div className="glass-selected glass-rim glass-plate glass-shadow flex h-10 items-center rounded-full px-5 text-sm">
Selected
</div>
<div className="glass glass-rim glass-plate glass-shadow flex items-center gap-1 rounded-full p-[4.5px]">
<span className="glass-selected-personal glass-rim flex h-7 items-center rounded-full px-4 text-xs font-semibold tracking-[0.07em] text-text-on-personal">
SELECTED TAB
</span>
<span className="flex h-7 items-center px-4 text-xs font-semibold tracking-[0.07em] text-text-secondary">
TAB
</span>
</div>
</div>
</Section>

Expand Down Expand Up @@ -222,13 +231,20 @@ export default function ShowcasePage() {
A TMDB badge with no value renders nothing; a personal score with no
value renders &ldquo;Not rated&rdquo;. Plan, Planned and the watched
marks are the movie tracking marks from spec 0007, drawn from the
legend&rsquo;s own paths. Next episode is the Watchlist show
card&rsquo;s pill, and the dated and Date TBA pills sit on Upcoming
cards (spec 0020); below <code>sm</code> a date that carries its year
drops the calendar glyph.
original legend&rsquo;s paths and listed in the footer&rsquo;s icon
key. Next episode is the Watchlist show card&rsquo;s pill, and the
dated and Date TBA pills sit on Upcoming cards (spec 0020); below{" "}
<code>sm</code> a date that carries its year drops the calendar glyph.
</p>
</Section>

<Section
title="Icon key"
note="The footer's key to the card marks, on every route. A new mark adds its entry there rather than a per page legend (prompts/grid-tabs-footer-legend.md)."
>
<IconKey />
</Section>

<Section title="Buttons and controls">
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Glass, sm</Button>
Expand Down
56 changes: 41 additions & 15 deletions app/shows/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -119,10 +123,29 @@ async function PopularShows({
title={show.name}
posterUrl={show.posterUrl}
href={`/shows/${show.id}`}
badge={<TmdbRatingBadge value={show.tmdbRating} />}
badge={
<Suspense
fallback={<TmdbRatingBadge value={show.tmdbRating} />}
>
<ShowCardBadge
showId={show.id}
tmdbRating={show.tmdbRating}
gridShowIds={gridShowIds}
/>
</Suspense>
}
meta={
<Suspense fallback={null}>
<ShowCardMeta
showId={show.id}
base={null}
gridShowIds={gridShowIds}
/>
</Suspense>
}
controls={
<Suspense fallback={null}>
<ShowCardBookmark
<ShowCardControls
showId={show.id}
name={show.name}
gridShowIds={gridShowIds}
Expand Down Expand Up @@ -161,13 +184,16 @@ function NoSuchPage() {

function PopularShowsSkeleton() {
return (
<PosterGrid aria-hidden="true">
{Array.from({ length: PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
<li key={index}>
<PosterCardSkeleton />
</li>
))}
</PosterGrid>
<div className="flex flex-col gap-10">
<PosterGrid aria-hidden="true">
{Array.from({ length: PAGE_SIZE }, (_, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static placeholders with no identity.
<li key={index}>
<PosterCardSkeleton />
</li>
))}
</PosterGrid>
<PaginationSkeleton />
</div>
);
}
Loading
Loading