Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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: 24 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,15 @@
--color-glass-plate-sheet: #090a0d;
--color-glass-plate-score: #0c1419;

/*
* The ambient background's two neutrals (prompts/ambient-background.md):
* the wave's trough colour, which its crests blend into the personal cyan,
* and the dust. Read by the renderer through `getComputedStyle`, never
* written in a shader, so this file stays the one home of every colour.
*/
--color-backdrop-wave: #8c99bf;
--color-backdrop-dust: #cfd3e0;

/* Text greys, beyond the shadcn semantic set. */
--color-text-label: #e2e5ef;
--color-text-link: #d7d9e0;
Expand Down Expand Up @@ -345,6 +354,21 @@
}
}

/*
* The ambient background's vignette (prompts/ambient-background.md): clear in
* the upper middle, where headings sit, and closing to the page black at the
* edges. It is in the prerendered HTML, so it is also the whole background
* until the animated layer fades in, and all of it where WebGL2 is missing.
*/
@utility backdrop-vignette {
background: radial-gradient(
ellipse at 50% 40%,
color-mix(in srgb, var(--background) 0%, transparent) 0%,
color-mix(in srgb, var(--background) 55%, transparent) 70%,
color-mix(in srgb, var(--background) 90%, transparent) 100%
);
}

/*
* Loading pulse, 2 seconds ease in out, cut entirely under reduced motion
* rather than merely slowed: a permanently animating page is the thing the
Expand Down
3 changes: 3 additions & 0 deletions app/layout-purity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ const LAYOUT_TREE = [
"components/layout/navbar.tsx",
"components/layout/media-type-tabs.tsx",
"components/layout/mobile-menu-sheet.tsx",
// The ambient background, behind every route (prompts/ambient-background.md).
"components/layout/ambient-background.tsx",
"components/layout/ambient-canvas.tsx",
// TMDB's attribution and the legal links, on every route (spec 0017, AC-1).
"components/layout/site-footer.tsx",
// Rendered inside the account slot rather than the layout, but it sits in
Expand Down
13 changes: 13 additions & 0 deletions app/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,16 @@ describe("the site footer in the root layout", () => {
expect(LAYOUT).toMatch(/<main className="[^"]*\bflex-1\b[^"]*"/);
});
});

/**
* covers: prompts/ambient-background.md, AC-1
*
* The background renders once, from the root layout, before everything else
* in the body, so every route has it and it stacks below the shell.
*/
describe("the ambient background in the root layout", () => {
it("renders exactly once, first inside <body>", () => {
expect(LAYOUT.match(/<AmbientBackground\s*\/>/g)).toHaveLength(1);
expect(LAYOUT).toMatch(/<body[^>]*>\s*<AmbientBackground \/>/);
});
});
7 changes: 7 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import { Suspense } from "react";

import { AccountSlot } from "@/components/layout/account-slot";
import { AmbientBackground } from "@/components/layout/ambient-background";
import { Navbar } from "@/components/layout/navbar";
import { SiteFooter } from "@/components/layout/site-footer";
import { Skeleton } from "@/components/skeleton";
Expand Down Expand Up @@ -69,6 +70,11 @@ export const metadata: Metadata = {
* through (spec 0007). It is a client boundary that reads no request state, so
* it costs no route its static shell.
*
* `AmbientBackground` paints behind everything on every route
* (prompts/ambient-background.md). Its only client piece mounts after the
* browser is idle and reads no request state, so it costs no route its static
* shell either.
*
* `AccountSlot` is the one exception, and a Suspense boundary around each of
* its two instances, one per navbar layout, is what contains it (spec 0005,
* AC-14; spec 0008). It reads the session, which is request scoped; without
Expand All @@ -81,6 +87,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={`${inter.variable} h-full`}>
<body className="flex min-h-full flex-col antialiased">
<AmbientBackground />
{/*
* Each fallback is the signed out button's footprint, so the bar does
* not resize when the real control arrives. A skeleton rather than a
Expand Down
29 changes: 29 additions & 0 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 { AmbientCanvas } from "@/components/layout/ambient-canvas";
import { IconKey } from "@/components/layout/icon-key";
import { LibraryNavView } from "@/components/layout/library-nav";
import { MobileMenuSheet } from "@/components/layout/mobile-menu-sheet";
Expand Down Expand Up @@ -398,6 +399,34 @@ export default function ShowcasePage() {
</Card>
</Section>

<Section
title="Ambient background"
note="Behind every page, this one included: a point wave, drifting outlined screens and dust in the personal score cyan, under the vignette. It fades in once the browser is idle, draws in a worker where it can, pauses in a hidden tab, and freezes into one still frame under reduced motion. Without WebGL2 only the vignette shows (prompts/ambient-background.md)."
>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Swatch name="--color-score-personal" className="bg-score-personal" />
<Swatch name="--color-backdrop-wave" className="bg-backdrop-wave" />
<Swatch name="--color-backdrop-dust" className="bg-backdrop-dust" />
<div className="flex flex-col gap-2">
<div className="relative h-14 overflow-hidden rounded-lg border border-border bg-score-personal">
<div className="absolute inset-0 backdrop-vignette" />
</div>
<code className="text-xs text-muted-foreground">
backdrop-vignette over cyan
</code>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<div className="relative h-64 overflow-hidden rounded-panel border border-border">
<AmbientCanvas still />
<div className="absolute inset-0 backdrop-vignette" />
</div>
<code className="text-xs text-muted-foreground">
the reduced motion still frame
</code>
</div>
</Section>

<Section
title="Legal text and the footer"
note="The pieces /privacy and /terms are built from (spec 0017). There is no typography plugin, so lists and links are styled by hand. The site footer itself renders below every page, including this one: flat, no glass, links at the 44px and 36px touch heights."
Expand Down
5 changes: 3 additions & 2 deletions components/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The rule is enforced by the components that own a glass surface (`GlassPill`, `P

All values live in `app/globals.css`, split across four blocks: `@theme inline` (the shadcn semantic remap and the radius scale), `@theme` (product tokens, which become real utilities such as `text-rating-tmdb` and `rounded-panel`), `:root` (the semantic values themselves), and the `@utility` definitions.

- **Three colours carry meaning and nothing else may borrow them**: `--color-rating-tmdb` amber is always a TMDB community rating, `--color-score-personal` cyan is always the user's own score, `--color-status-planned` green is always Planned. One deliberate exception, chosen by the owner on 2026-10-11: the selected navbar tab (`glass-selected-personal`) is lit in the personal cyan. The focus ring deliberately uses none of them.
- **Three colours carry meaning and nothing else may borrow them**: `--color-rating-tmdb` amber is always a TMDB community rating, `--color-score-personal` cyan is always the user's own score, `--color-status-planned` green is always Planned. Two deliberate exceptions, both chosen by the owner on 2026-10-11: the selected navbar tab (`glass-selected-personal`) is lit in the personal cyan, and so is the ambient background behind every page ([prompts/ambient-background.md](../prompts/ambient-background.md)), which stays faint enough that a rating badge on its opaque plate never reads as part of it. The focus ring deliberately uses none of them.
- **Four plates**: `glass-plate` for controls and badges, `glass-plate-panel` for panels, `glass-plate-sheet` for the menu sheet and overlays, `glass-plate-score` for the personal score badge.
- **Surface utilities**: `glass` (plate plus gradient plus rim), `glass-selected` (the brighter active variant), `glass-selected-personal` (the selected navbar tab, tinted from the personal score cyan, with `text-on-personal` dark text for contrast), `plate-rim` (plate and rim, no gradient, for surfaces that should read as an absence: the missing poster tile and the skeletons), `rim` (the bare gradient stroke over artwork). Pick the rim colour with `glass-rim` or `glass-rim-score`.
- `rim` masks its own padding box out, so it clips everything the element paints. It belongs on an empty decorative overlay, never on an element with content or a background of its own.
Expand All @@ -25,12 +25,13 @@ All values live in `app/globals.css`, split across four blocks: `@theme inline`

## Conventions

- **Server Components by default.** The only client boundaries are `layout/media-type-tabs.tsx`, `layout/brand-link.tsx`, `layout/mobile-menu-sheet.tsx`, `layout/library-nav.tsx` (rendered only inside the request scoped `AccountSlot`, spec 0008), and the three shadcn primitives that need state (`ui/dialog.tsx`, `ui/toggle.tsx`, `ui/toggle-group.tsx`). None reads a server dynamic API, so routes still prerender under `cacheComponents`. Adding a client boundary in the shell is a decision, not a convenience.
- **Server Components by default.** The only client boundaries are `layout/media-type-tabs.tsx`, `layout/brand-link.tsx`, `layout/mobile-menu-sheet.tsx`, `layout/library-nav.tsx` (rendered only inside the request scoped `AccountSlot`, spec 0008), `layout/ambient-canvas.tsx` (the background's animated layer), and the three shadcn primitives that need state (`ui/dialog.tsx`, `ui/toggle.tsx`, `ui/toggle-group.tsx`). None reads a server dynamic API, so routes still prerender under `cacheComponents`. Adding a client boundary in the shell is a decision, not a convenience.
- **Selection comes from the URL**, not client state. `MediaTypeTabs` reads `usePathname`: the catalog and title pages light their own tab, and pages about neither catalog (`/account`, `/privacy`, `/terms`, the auth pages, `/showcase`, the 404 page) render no tabs, no search and no Library links, in the bar or the mobile sheet ([prompts/hide-tabs-without-type.md](../prompts/hide-tabs-without-type.md)). On `/watchlist`, `/upcoming`, `/watched` and `/search` it also reads `?type=` through `useSearchParams`, and each tab links to the same page with the other type (feature 22). That read happens only on those paths and in its own Suspense boundary, inside `layout/page-media-type.tsx` (`WithPageMediaType`, which `BrandLink` uses, and `WithTypedPage`, which renders nothing on a page with no type and which the tabs, `LibraryNav` and `NavbarSearch` use). It carries no `"use client"` of its own, so it adds no boundary to the list above. Never call `useSearchParams` unconditionally in the shell, or every route loses its prerendered shell. On a route with a dynamic param `usePathname` suspends at prerender time, so `Navbar` wraps the tabs in Suspense with `MediaTypeTabsView selected={null}` (same control, nothing lit) as the fallback. Keep that boundary, or `/movies/[id]` loses its static shell (spec 0006). The Library links carry the current page's type, so a tab chosen on `/movies` is still selected on `/watchlist`. The logo (`BrandLink`) links to the page's catalog, and on a page with no type to the last one viewed, kept in `localStorage` as a per browser convenience that never lights a tab ([prompts/logo-follows-media-type.md](../prompts/logo-follows-media-type.md)). It has its own Suspense boundary for the same dynamic route reason.
- **A retry is a full page load.** `RetryLink` is a plain `<a>`, not `next/link`, so the client router cache cannot replay the failure the visitor is retrying past (spec 0006, AC-10).
- **Focus** is a 2px `#F6F7F8` outline at 2px offset on `:focus-visible` only, driven by `--ring` in the base layer. Do not restate it per component.
- **Backdrop blur** (`--blur-glass`, 16px, no saturation) goes only on surfaces that overlap content: the menu sheet, the quick search dropdown, and badges over posters. Glass over flat black carries no blur.
- **The navbar is transparent and not sticky.** It has no background, border or blur and scrolls away with the page: pinned over scrolling content, a transparent bar is unreadable. Do not make it sticky again without giving it a background. Every control in it is 40px tall at every width: the `bar` and `icon-bar` button sizes, `h-7` tabs inside a pill padded so tab, padding and the 1.5px rim add up to 40px, and an `h-10` search field. The `hit-area` utility (and `hit-area-tab` for a tab) stretches each tap area back to 44px, so the touch rule still holds.
- **The ambient background** (`layout/ambient-background.tsx`, rendered first in `<body>` by the root layout) is fixed at `-z-10`, `aria-hidden` and `pointer-events-none`; only its vignette is in the prerendered HTML. `AmbientCanvas` imports `lib/backdrop/mount.ts` when the browser is idle, which draws 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 vignette alone without WebGL2 or after a lost context. Every layer 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 `POSTER_SIZES` string exported by `poster-card.tsx` so it stays matched to the grid's column counts (2 below `md`, 4 from it, in a grid capped at 1200px and centred, so a 20 title page is 5 full rows; prompts/grid-tabs-footer-legend.md).
Expand Down
56 changes: 56 additions & 0 deletions components/layout/ambient-background.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mountBackdrop = vi.fn();
vi.mock("@/lib/backdrop/mount", () => ({ mountBackdrop }));

import { AmbientBackground } from "./ambient-background";

/** covers: prompts/ambient-background.md, AC-1, AC-3, AC-7 */
describe("AmbientBackground", () => {
beforeEach(() => {
vi.useFakeTimers();
mountBackdrop.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});

it("is hidden from assistive technology and the pointer, behind the page", () => {
const { container } = render(<AmbientBackground />);
const root = container.firstElementChild as HTMLElement;
expect(root).toHaveAttribute("aria-hidden", "true");
expect(root).toHaveClass("pointer-events-none", "fixed", "-z-10");
expect(root.querySelector("a, button, input, [tabindex]")).toBeNull();
});

it("renders the static vignette before anything loads", () => {
const { container } = render(<AmbientBackground />);
expect(container.querySelector(".backdrop-vignette")).not.toBeNull();
expect(container.querySelector("canvas")).toBeNull();
expect(mountBackdrop).not.toHaveBeenCalled();
});

it("starts the scene once the browser is idle, and stops it on unmount", async () => {
const cleanup = vi.fn();
mountBackdrop.mockReturnValue(cleanup);
const { unmount } = render(<AmbientBackground />);

await act(async () => {
await vi.runAllTimersAsync();
});
expect(mountBackdrop).toHaveBeenCalledTimes(1);

unmount();
expect(cleanup).toHaveBeenCalledTimes(1);
});

it("never starts when unmounted before the idle moment", async () => {
const { unmount } = render(<AmbientBackground />);
unmount();
await act(async () => {
await vi.runAllTimersAsync();
});
expect(mountBackdrop).not.toHaveBeenCalled();
});
});
28 changes: 28 additions & 0 deletions components/layout/ambient-background.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { AmbientCanvas } from "@/components/layout/ambient-canvas";

/**
* The ambient background behind every page (prompts/ambient-background.md):
* a point wave, drifting outlined screens and dust in the personal score cyan,
* under a vignette.
*
* A Server Component, so the vignette is in the prerendered HTML and the first
* paint is the same black page as before, darkened at the edges; the animated
* layer fades in under it once the browser is idle.
*
* Fixed behind the content at `-z-10`, which paints above the body's black
* (propagated to the viewport) and below everything else, so nothing in the
* shell needs its own stacking context. Sized to the large viewport height so
* a phone toolbar collapsing on scroll never resizes it. Hidden from
* assistive technology and from the pointer: it is decoration only.
*/
export function AmbientBackground() {
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 -z-10 h-lvh select-none"
>
<AmbientCanvas />
<div className="absolute inset-0 backdrop-vignette" />
</div>
);
}
Loading
Loading