diff --git a/app/globals.css b/app/globals.css index e360590..985b973 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; @@ -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 diff --git a/app/layout-purity.test.ts b/app/layout-purity.test.ts index 7c8477f..9559b30 100644 --- a/app/layout-purity.test.ts +++ b/app/layout-purity.test.ts @@ -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 diff --git a/app/layout.test.ts b/app/layout.test.ts index 903822c..7fb791f 100644 --- a/app/layout.test.ts +++ b/app/layout.test.ts @@ -41,3 +41,16 @@ describe("the site footer in the root layout", () => { expect(LAYOUT).toMatch(/
{ + it("renders exactly once, first inside ", () => { + expect(LAYOUT.match(//g)).toHaveLength(1); + expect(LAYOUT).toMatch(/]*>\s*/); + }); +}); diff --git a/app/layout.tsx b/app/layout.tsx index 3bade83..0f6dccb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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"; @@ -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 @@ -81,6 +87,7 @@ export default function RootLayout({ children }: LayoutProps<"/">) { return ( + {/* * 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 diff --git a/app/showcase/page.tsx b/app/showcase/page.tsx index f3a5be3..4cd1606 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -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"; @@ -398,6 +399,34 @@ export default function ShowcasePage() { +
+
+ + + +
+
+
+
+ + backdrop-vignette over cyan + +
+
+
+
+ +
+
+ + the reduced motion still frame + +
+
+
`, 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 `` 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). diff --git a/components/layout/ambient-background.test.tsx b/components/layout/ambient-background.test.tsx new file mode 100644 index 0000000..4288b33 --- /dev/null +++ b/components/layout/ambient-background.test.tsx @@ -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(); + 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(); + 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(); + + 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(); + unmount(); + await act(async () => { + await vi.runAllTimersAsync(); + }); + expect(mountBackdrop).not.toHaveBeenCalled(); + }); +}); diff --git a/components/layout/ambient-background.tsx b/components/layout/ambient-background.tsx new file mode 100644 index 0000000..1e1823d --- /dev/null +++ b/components/layout/ambient-background.tsx @@ -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 ( +