diff --git a/app/glass-contrast.test.ts b/app/glass-contrast.test.ts new file mode 100644 index 0000000..fcbbb19 --- /dev/null +++ b/app/glass-contrast.test.ts @@ -0,0 +1,232 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + channelsFromComputed, + channelsFromHex, + contrastRatio as contrast, + type Rgb, + relativeLuminance, + TEXT_CONTRAST, +} from "@/lib/color/contrast"; + +/** + * covers: prompts/frosted-glass.md, AC-3 + * + * The frosted plates trade opacity for legibility, and the trade is only + * safe at the values this file checks. Every number is read from + * `globals.css` (and the dimming backdrop from the components that draw it), + * so lowering a frost value, brightening the sheen or darkening a text token + * fails here rather than on someone's screen. + * + * Composited in gamma encoded sRGB, the way browsers blend: the glass + * gradient's stops over the plate at its frost opacity over the worst pixel + * that can sit behind that surface. + */ + +const CSS = readFileSync("app/globals.css", "utf8"); + +/** The hex value a token is declared with in globals.css. */ +function hex(token: string): Rgb { + const match = CSS.match(new RegExp(`${token}:\\s*(#[0-9a-f]{6})`, "i")); + const channels = match && channelsFromHex(match[1]); + if (!channels) throw new Error(`${token} is not a hex token`); + return channels; +} + +/** The first declaration of a percentage token, the one `:root` sets. */ +function percent(token: string): number { + const match = CSS.match(new RegExp(`${token}:\\s*(\\d+)%`)); + if (!match) throw new Error(`${token} is not a percentage`); + return Number(match[1]) / 100; +} + +/** The colour and alpha of each stop in a utility's first gradient. */ +function stops(utility: string): { color: Rgb; alpha: number }[] { + const start = CSS.indexOf(`@utility ${utility} {`); + const block = CSS.slice(start, CSS.indexOf("padding-box", start)); + return [...block.matchAll(/rgb\(([\d ]+) \/ ([\d.]+)\)/g)].map( + ([, channels, alpha]) => { + const color = channelsFromComputed(channels); + if (!color) throw new Error(`${utility} has an unreadable stop`); + return { color, alpha: Number(alpha) }; + }, + ); +} + +function over(top: Rgb, alpha: number, bottom: Rgb): Rgb { + return top.map((c, i) => c * alpha + bottom[i] * (1 - alpha)) as Rgb; +} + +/** The sRGB grey whose relative luminance is `target`. */ +function greyOf(target: number): Rgb { + const linear = target; + const c = + linear <= 0.0031308 ? linear * 12.92 : 1.055 * linear ** (1 / 2.4) - 0.055; + return [c, c, c]; +} + +const BLACK: Rgb = [0, 0, 0]; +const WHITE: Rgb = [1, 1, 1]; + +/** + * The background's brightest pixel. The scene caps every fragment so muted + * text keeps 4.5:1 over it (`peakLuminance`, which also shaves a safety + * margin); this uses the uncapped ceiling, the stricter case. + */ +const PEAK = greyOf( + (relativeLuminance(hex("--muted-foreground")) + 0.05) / TEXT_CONTRAST - 0.05, +); + +/** White behind the dialog and menu sheet's dimming backdrop. */ +const DIMMED_WHITE = (() => { + const dims = [ + "components/ui/dialog.tsx", + "components/layout/mobile-menu-sheet.tsx", + ].map((path) => { + const match = readFileSync(path, "utf8").match(/bg-background\/(\d+)/); + if (!match) throw new Error(`${path} lost its dimming backdrop`); + return Number(match[1]) / 100; + }); + return over(BLACK, Math.min(...dims), WHITE); +})(); + +/** Every text colour a frosted surface carries. */ +const TEXT = { + foreground: hex("--foreground"), + secondary: hex("--color-text-secondary"), + label: hex("--color-text-label"), + link: hex("--color-text-link"), + muted: hex("--muted-foreground"), + destructive: hex("--destructive"), +}; + +const ROLES = [ + { + role: "control", + plate: "--color-glass-plate", + frost: "--frost-control", + behind: PEAK, + text: TEXT, + }, + { + role: "panel", + plate: "--color-glass-plate-panel", + frost: "--frost-panel", + behind: PEAK, + text: TEXT, + }, + { + role: "sheet", + plate: "--color-glass-plate-sheet", + frost: "--frost-sheet", + behind: DIMMED_WHITE, + text: TEXT, + }, + { + role: "float", + plate: "--color-glass-plate-sheet", + frost: "--frost-float", + behind: WHITE, + text: TEXT, + }, + { + role: "score", + plate: "--color-glass-plate-score", + frost: "--frost-score", + behind: PEAK, + text: { + personal: hex("--color-score-personal"), + foreground: TEXT.foreground, + }, + }, +] as const; + +/** The weakest contrast of any text on the surface, at any gradient stop. */ +function weakest( + plate: Rgb, + opacity: number, + behind: Rgb, + text: Record, + gradient = stops("glass"), +): number { + const base = over(plate, opacity, behind); + return Math.min( + ...gradient.flatMap(({ color, alpha }) => + Object.values(text).map((t) => contrast(t, over(color, alpha, base))), + ), + ); +} + +describe("frosted plates keep AA text (prompts/frosted-glass.md)", () => { + for (const { role, plate, frost, behind, text } of ROLES) { + it(`keeps every ${role} text colour at 4.5:1 over its worst background`, () => { + expect( + weakest(hex(plate), percent(frost), behind, text), + ).toBeGreaterThanOrEqual(TEXT_CONTRAST); + }); + } + + it("keeps near white text at 4.5:1 on a selected surface, whose plate stays solid", () => { + const selected = weakest( + hex("--color-glass-plate"), + 1, + BLACK, + { + foreground: TEXT.foreground, + }, + stops("glass-selected"), + ); + expect(selected).toBeGreaterThanOrEqual(TEXT_CONTRAST); + }); + + it("keeps the selected tab's dark text at 4.5:1 on its darkest stop", () => { + const cyan = over( + hex("--color-score-personal"), + 0.8, + hex("--color-glass-plate"), + ); + expect( + contrast(hex("--color-text-on-personal"), cyan), + ).toBeGreaterThanOrEqual(TEXT_CONTRAST); + }); + + it("holds white artwork under the navbar no brighter than the background's peak", () => { + // The title backdrop reaches up behind the bar under `backdrop-scrim`, so + // the bar's controls must see nothing brighter than the PEAK they are + // checked against above. + const start = CSS.indexOf("@utility backdrop-scrim {"); + expect(start).toBeGreaterThan(-1); + // Every stop up to the bar's bottom edge, the faintest of which counts. + const acrossBar = CSS.slice( + start, + CSS.indexOf("var(--navbar-height)", start), + ); + const shades = [...acrossBar.matchAll(/var\(--background\) (\d+)%/g)].map( + ([, percent]) => Number(percent) / 100, + ); + expect(shades.length).toBeGreaterThan(0); + const behindBar = over(hex("--background"), Math.min(...shades), WHITE); + expect(relativeLuminance(behindBar)).toBeLessThanOrEqual( + relativeLuminance(PEAK), + ); + }); + + it("turns every plate solid and stops the blur in each fallback", () => { + const fallbacks = [ + "@media (prefers-reduced-transparency: reduce), (prefers-contrast: more)", + "@supports not (", + ]; + for (const opening of fallbacks) { + const start = CSS.indexOf(opening); + expect(start, opening).toBeGreaterThan(-1); + const block = CSS.slice(start, CSS.indexOf("}\n}", start)); + for (const { frost } of ROLES) { + expect(block).toContain(`${frost}: 100%;`); + } + for (const blur of ["control", "panel", "overlay"]) { + expect(block).toContain(`--frost-blur-${blur}: none;`); + } + } + }); +}); diff --git a/app/globals.css b/app/globals.css index 985b973..63f559a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -78,13 +78,31 @@ --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. + * The page background (prompts/film-strip-background.md): the teal + * gradient's four stops, the film strip's frame, the three lights it is lit + * by and the four bokeh tints, all the design file's own values. Page + * background only: no surface or text may use them. "Backdrop" is kept for + * a title's TMDB artwork (`backdrop-scrim`), so the two never share a + * name. The scene reads them through `getComputedStyle`, never writing one + * in a shader, so this file stays the one home of every colour. */ - --color-backdrop-wave: #8c99bf; - --color-backdrop-dust: #cfd3e0; + --color-page-glow: #04303a; + --color-page-mid: #021820; + --color-page-deep: #010a10; + --color-page-abyss: #000306; + --color-film-base: #0e6a80; + --color-film-window: #05303a; + --color-film-shadow: #01090c; + --color-film-edge: #4fe0ff; + --color-film-rail: #3fd8ff; + --color-film-glow: #1fb8d8; + --color-light-ambient: #145060; + --color-light-key: #a8f4ff; + --color-light-rim: #2fd8ff; + --color-bokeh-1: #8ff0ff; + --color-bokeh-2: #c9f8ff; + --color-bokeh-3: #ffffff; + --color-bokeh-4: #4fd8f0; /* Text greys, beyond the shadcn semantic set. */ --color-text-label: #e2e5ef; @@ -101,6 +119,16 @@ * settled rather than read (AC-16). */ --blur-glass: 16px; + + /* + * The frosted plates' blur, by surface size (prompts/frosted-glass.md, + * decision 6): a small control needs less to soften what moves behind it + * than a panel or an overlay floating over content. No saturation + * adjustment, so the contrast test's composite stays the real one. + */ + --blur-control: 12px; + --blur-panel: 20px; + --blur-overlay: 24px; } :root { @@ -129,7 +157,9 @@ * The first pick, `#F87171`, measured 3.90:1 against the brightest point of * the glass gradient over the control plate, which fails AA for body text. * Raised to the nearest lighter red that clears it at 4.67:1 (AC-7, and - * recorded in the spec's `rationale.md`). + * recorded in the spec's `rationale.md`). The frosted control plate over the + * background's brightest pixel, under the softer sheen, keeps it above + * 4.5:1 too (`app/glass-contrast.test.ts`). */ --destructive: #fb8a8a; --border: rgb(255 255 255 / 12%); @@ -144,6 +174,77 @@ --ring: #f6f7f8; --radius: 0.75rem; + + /* + * How opaque each frosted plate is (prompts/frosted-glass.md). Every text + * colour on that surface must keep 4.5:1 over the worst pixel that can sit + * behind it: the background's luminance cap for controls and panels, white + * under the dimming backdrop for the sheet, and bare white for a float, + * which can land on a poster. Controls and floats sit on that limit; the + * others were set at the plan's values, a step or more above it. + * `app/glass-contrast.test.ts` recomputes all of it from this file, so + * lowering one of these fails the suite rather than the reader. + */ + --frost-control: 65%; + --frost-panel: 60%; + --frost-sheet: 75%; + --frost-float: 90%; + --frost-score: 65%; + --frost-blur-control: blur(var(--blur-control)); + --frost-blur-panel: blur(var(--blur-panel)); + --frost-blur-overlay: blur(var(--blur-overlay)); + + /* + * The navbar's height: two 40px rows, an 8px gap and 8px of padding above + * and below on a phone, one row from `md`, written out in those parts. A + * title page's backdrop pulls up by it to sit behind the bar + * (`components/catalog/detail-hero.tsx`). The bar keeps its natural height, + * because pages with no tabs have one row on a phone too, so + * `navbar.test.tsx` fails if its padding or gap stops matching these parts. + */ + --navbar-height: calc(2 * 2.5rem + 0.5rem + 2 * 0.5rem); +} + +@media (width >= 48rem) { + :root { + --navbar-height: calc(2.5rem + 2 * 0.5rem); + } +} + +/* + * Glass made solid again (prompts/frosted-glass.md, decision 5): for anyone + * who asks the system for less transparency or more contrast, and for a + * browser that cannot blur, where a see-through plate would show the moving + * background sharp behind the text. Every plate returns to its opaque token + * and nothing blurs. + */ +@media (prefers-reduced-transparency: reduce), (prefers-contrast: more) { + :root { + --frost-control: 100%; + --frost-panel: 100%; + --frost-sheet: 100%; + --frost-float: 100%; + --frost-score: 100%; + --frost-blur-control: none; + --frost-blur-panel: none; + --frost-blur-overlay: none; + } +} + +@supports not ( + (backdrop-filter: blur(1px)) or + (-webkit-backdrop-filter: blur(1px)) + ) { + :root { + --frost-control: 100%; + --frost-panel: 100%; + --frost-sheet: 100%; + --frost-float: 100%; + --frost-score: 100%; + --frost-blur-control: none; + --frost-blur-panel: none; + --frost-blur-overlay: none; + } } /* @@ -161,6 +262,11 @@ * regardless of the order the classes are written in. The plate is deliberately * NOT applied by `glass` itself, so the showcase can render the unreadable * plate-less case beside the correct one (AC-5). + * + * The sheen's top stop is 12% white, two thirds of the original 18% + * (prompts/frosted-glass.md, decision 9): over a see-through plate the sheen + * costs more contrast than the plate does, so a softer sheen is what lets the + * plates go frosted at all. */ @utility glass { @@ -168,9 +274,9 @@ background: linear-gradient( 169deg, - rgb(255 255 255 / 0.18) 0%, - rgb(213 217 241 / 0.055) 45%, - rgb(179 188 216 / 0.1) 100% + rgb(255 255 255 / 0.12) 0%, + rgb(213 217 241 / 0.037) 45%, + rgb(179 188 216 / 0.067) 100% ) padding-box, linear-gradient( @@ -192,8 +298,8 @@ ) padding-box, linear-gradient( - var(--glass-plate, transparent) 0%, - var(--glass-plate, transparent) 100% + var(--glass-plate-solid, var(--glass-plate, transparent)) 0%, + var(--glass-plate-solid, var(--glass-plate, transparent)) 100% ) padding-box, var(--glass-rim, none) border-box; @@ -228,8 +334,8 @@ ) padding-box, linear-gradient( - var(--glass-plate, transparent) 0%, - var(--glass-plate, transparent) 100% + var(--glass-plate-solid, var(--glass-plate, transparent)) 0%, + var(--glass-plate-solid, var(--glass-plate, transparent)) 100% ) padding-box, var(--glass-rim, none) border-box; @@ -243,7 +349,8 @@ * Same two layer technique as `glass`, minus the fill. It depends on the plate * being opaque: `background-clip: border-box` paints across the whole element, * not just the border, so it is the plate on `padding-box` above it that - * reduces the rim to a ring. Without a plate this floods. + * reduces the rim to a ring. Without a plate this floods, and through a + * frosted one it shows, so every `plate-rim` also carries `plate-solid`. */ @utility plate-rim { border: 1.5px solid transparent; @@ -294,20 +401,85 @@ ); } +/* + * The plates (prompts/frosted-glass.md). Each sets its colour at the role's + * frost opacity and the blur that comes with it, so a see-through plate can + * never ship without the blur that keeps the moving background soft behind + * its text. `--glass-plate-solid` carries the opaque colour for the selected + * utilities, which stay solid so near white text keeps 4.5:1 under their + * brighter gradient. `plate-solid` makes a plate opaque again. + * + * Each role sets only its colour, frost and blur; `glass-plate-frost` turns + * those into the plate, written once, and only the roles apply it. + */ +@utility glass-plate-frost { + --glass-plate-solid: var(--plate-color); + --glass-plate: color-mix( + in srgb, + var(--plate-color) var(--plate-frost), + transparent + ); + -webkit-backdrop-filter: var(--plate-blur); + backdrop-filter: var(--plate-blur); +} + @utility glass-plate { - --glass-plate: var(--color-glass-plate); + --plate-color: var(--color-glass-plate); + --plate-frost: var(--frost-control); + --plate-blur: var(--frost-blur-control); + @apply glass-plate-frost; } @utility glass-plate-panel { - --glass-plate: var(--color-glass-plate-panel); + --plate-color: var(--color-glass-plate-panel); + --plate-frost: var(--frost-panel); + --plate-blur: var(--frost-blur-panel); + @apply glass-plate-frost; } +/* The dialog and the menu sheet, both over the dimming backdrop. */ @utility glass-plate-sheet { - --glass-plate: var(--color-glass-plate-sheet); + --plate-color: var(--color-glass-plate-sheet); + --plate-frost: var(--frost-sheet); + --plate-blur: var(--frost-blur-overlay); + @apply glass-plate-frost; +} + +/* + * What floats over the page with no dimming backdrop: the popover, the quick + * search panel and the toasts. Any of them can land on a white poster, so + * this is the most opaque plate. + */ +@utility glass-plate-float { + --plate-color: var(--color-glass-plate-sheet); + --plate-frost: var(--frost-float); + --plate-blur: var(--frost-blur-overlay); + @apply glass-plate-frost; } @utility glass-plate-score { - --glass-plate: var(--color-glass-plate-score); + --plate-color: var(--color-glass-plate-score); + --plate-frost: var(--frost-score); + --plate-blur: var(--frost-blur-control); + @apply glass-plate-frost; +} + +/* + * Opaque plates, no blur, for this element and everything inside it: the + * poster card's badge and control slots, where artwork of any colour sits + * behind (the plate rule), and the skeletons and placeholder tiles, which + * stand in for a poster and should read as an absence. It works through + * inheritance, so the slot decides and the pills inside need no switch. + */ +@utility plate-solid { + --frost-control: 100%; + --frost-panel: 100%; + --frost-sheet: 100%; + --frost-float: 100%; + --frost-score: 100%; + --frost-blur-control: none; + --frost-blur-panel: none; + --frost-blur-overlay: none; } /* @@ -355,17 +527,56 @@ } /* - * 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. + * The page background's gradient (prompts/film-strip-background.md): the + * design's teal, brightest along the top left. It is in the prerendered HTML, + * so it is the first paint, and with the vignette the whole background where + * WebGL2 is missing. */ -@utility backdrop-vignette { +@utility page-gradient { 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% + ellipse at 30% 5%, + var(--color-page-glow) 0%, + var(--color-page-mid) 45%, + var(--color-page-deep) 80%, + var(--color-page-abyss) 100% + ); +} + +/* + * The vignette over the scene: lit from above, so it is lightest along the + * top and closes to near black at the edges, with a second layer deepening + * the lower half. It only ever darkens, so the scene's contrast cap, which + * counts the gradient without it, still holds. + */ +@utility page-vignette { + background: + linear-gradient( + to bottom, + transparent 35%, + color-mix(in srgb, var(--color-page-abyss) 20%, transparent) 75%, + color-mix(in srgb, var(--color-page-abyss) 40%, transparent) 100% + ), + radial-gradient( + ellipse at 50% 20%, + color-mix(in srgb, var(--color-page-abyss) 5%, transparent) 0%, + color-mix(in srgb, var(--color-page-abyss) 45%, transparent) 60%, + color-mix(in srgb, var(--color-page-abyss) 90%, transparent) 100% + ); +} + +/* + * The shade at the top of a title page's backdrop, under the navbar. Held at + * 75% black across the bar, any artwork behind it stays darker than the page + * background's brightest pixel, so the bar's text and frosted controls keep + * the contrast `app/glass-contrast.test.ts` proves over that background. It + * then lifts over the next 8rem so the artwork comes through below the bar. + */ +@utility backdrop-scrim { + background: linear-gradient( + to bottom, + color-mix(in srgb, var(--background) 75%, transparent) 0, + color-mix(in srgb, var(--background) 75%, transparent) var(--navbar-height), + transparent calc(var(--navbar-height) + 8rem) ); } diff --git a/app/layout-purity.test.ts b/app/layout-purity.test.ts index 9559b30..1348fb9 100644 --- a/app/layout-purity.test.ts +++ b/app/layout-purity.test.ts @@ -27,9 +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", + // The page background, behind every route (prompts/film-strip-background.md). + "components/layout/page-background.tsx", + "components/layout/page-background-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 7fb791f..479f667 100644 --- a/app/layout.test.ts +++ b/app/layout.test.ts @@ -43,14 +43,14 @@ describe("the site footer in the root layout", () => { }); /** - * covers: prompts/ambient-background.md, AC-1 + * covers: prompts/film-strip-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", () => { +describe("the page background in the root layout", () => { it("renders exactly once, first inside ", () => { - expect(LAYOUT.match(//g)).toHaveLength(1); - expect(LAYOUT).toMatch(/]*>\s*/); + expect(LAYOUT.match(//g)).toHaveLength(1); + expect(LAYOUT).toMatch(/]*>\s*/); }); }); diff --git a/app/layout.tsx b/app/layout.tsx index 0f6dccb..85cf0dd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,8 @@ 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 { PageBackground } from "@/components/layout/page-background"; import { SiteFooter } from "@/components/layout/site-footer"; import { Skeleton } from "@/components/skeleton"; import { Toaster } from "@/components/ui/sonner"; @@ -70,8 +70,8 @@ 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 + * `PageBackground` paints behind everything on every route + * (prompts/film-strip-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. * @@ -87,7 +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 4cd1606..cc5932a 100644 --- a/app/showcase/page.tsx +++ b/app/showcase/page.tsx @@ -3,10 +3,10 @@ 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"; +import { PageBackgroundCanvas } from "@/components/layout/page-background-canvas"; import { ContactEmail, ExternalLink, @@ -176,14 +176,21 @@ export default function ShowcasePage() {
+
+ Solid on art +
- Correct + {/* Deliberately frosted over artwork: legible only off posters. */} + Frosted on art
+
+ {( + [ + ["glass-plate", "Control 65%"], + ["glass-plate-panel", "Panel 60%"], + ["glass-plate-sheet", "Sheet 75%"], + ["glass-plate-float", "Float 90%"], + ] as const + ).map(([plate, label]) => ( + + {label} + + ))} + + Solid, the fallback look + +
@@ -400,30 +427,46 @@ export default function ShowcasePage() {
- - - + + + + + + + + + + + + + + + + + +
+
-
-
+
+
- backdrop-vignette over cyan + page-gradient and page-vignette, the first paint
-
-
-
- -
+
+
+ +
+
+ + the reduced motion still frame +
- - the reduced motion still frame -
diff --git a/components/AGENTS.md b/components/AGENTS.md index 4c4168e..84e4c8b 100644 --- a/components/AGENTS.md +++ b/components/AGENTS.md @@ -8,7 +8,7 @@ Every shared piece of interface lives here. The theme it reads from lives in [`a A glass surface on its own is close to invisible over artwork. Near white text on the bare glass gradient over a light poster measures 1.59:1. -So: **plate first, then glass, then rim.** Every glass surface that can land on artwork paints an opaque plate underneath it. +So: **plate first, then glass, then rim.** Off posters the plates are frosted ([prompts/frosted-glass.md](../prompts/frosted-glass.md)): see-through enough to show the page background, and always blurred so it stays soft behind the text. Anything over artwork sits inside `plate-solid`, which makes every plate in it opaque again: the poster card's badge and control slots, the skeletons and the quick search placeholder tiles. A new surface over a poster goes in a `plate-solid` slot rather than choosing its own plate. The rule is enforced by the components that own a glass surface (`GlassPill`, `PosterCard`'s badge and control slots, `MobileMenuSheet`, `StatePanel`), never by the `glass` utility itself. That is deliberate: `/showcase` renders the plate-less case beside the correct one, so the rule stays visible rather than becoming folklore. If you write `glass` without a `glass-plate-*` alongside it, you are opting out of legibility on purpose. @@ -16,22 +16,22 @@ 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. 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`. +- **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 page background has its own teal tokens (`--color-page-*`, `--color-film-*`, `--color-light-*`, `--color-bokeh-*`), which are for the background only; no surface or text may use them ([prompts/film-strip-background.md](../prompts/film-strip-background.md)). The focus ring deliberately uses none of them. +- **Five plates**, each a frosted colour plus its blur: `glass-plate` for controls and badges (65%, 12px), `glass-plate-panel` for panels (60%, 20px), `glass-plate-sheet` for the dialog and menu sheet over their dimming backdrop (75%, 24px), `glass-plate-float` for what floats with no backdrop, the popover, quick search panel and toasts (90%, 24px), and `glass-plate-score` for the personal score badge (65%, 12px). The opacities are the `--frost-*` tokens; [`app/glass-contrast.test.ts`](../app/glass-contrast.test.ts) recomputes 4.5:1 for every text colour over each plate's worst background, so lower one only if that still passes. `prefers-reduced-transparency: reduce`, `prefers-contrast: more` and a browser without `backdrop-filter` get opaque plates and no blur. The selected utilities always paint the solid plate (`--glass-plate-solid`). The glass gradient's sheen tops out at 12% white; brightening it costs the frosted plates their contrast. +- **Surface utilities**: `plate-solid` (opaque plates and no blur for the element and everything inside it), `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, which also carry `plate-solid`, because a rim shows through a frosted plate), `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. - The rim uses the two layer background technique: plate gradient on `padding-box`, rim gradient on `border-box`, over a transparent 1.5px border. Not `border-image`, which ignores `border-radius`. - **No component writes a colour literal.** [`design-tokens-boundary.test.ts`](../design-tokens-boundary.test.ts) walks the repository and fails the suite if a token hex, a Geist reference, or a dark variant appears outside `globals.css`, in the shape [`security-boundary.test.ts`](../security-boundary.test.ts) already established for the service role key. ## 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), `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. +- **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/page-background-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 ``, 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. +- **Backdrop blur** comes with the plate utilities (`--blur-control`, `--blur-panel`, `--blur-overlay`, no saturation), so never add `backdrop-blur-*` to a frosted surface yourself. `--blur-glass` (16px) remains for the dialog backdrops and the poster badge slots. Only the outermost large surface frosts: a highlighted row or panel inside a panel takes no plate class of its own. Small controls inside a panel keep theirs; blur cost follows area, and a 22 episode season page held 120fps while scrolling over the animated background. +- **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. It is `relative z-20` so a title page's backdrop can pull up behind it (`DetailHero` and its skeleton subtract `--navbar-height`), under a `backdrop-scrim` that holds the artwork at 75% black across the bar so its text and frosted controls keep their contrast. If the bar's padding, gap or rows change, change `--navbar-height` in `globals.css` with it; `navbar.test.tsx` fails until they match, and `app/glass-contrast.test.ts` checks the scrim's shade. 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 page background** (`layout/page-background.tsx`, rendered first in `` by the root layout) is fixed at `-z-10`, `aria-hidden` and `pointer-events-none`; its teal gradient (`page-gradient`) and vignette (`page-vignette`) are in the prerendered HTML and are the first paint. `PageBackgroundCanvas` imports `lib/film-scene/start.ts` when the browser is idle, which draws the film strips and bokeh 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 gradient and vignette alone without WebGL2 or after a lost context. Every fragment 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/catalog/detail-hero.tsx b/components/catalog/detail-hero.tsx index 75be0de..1d47305 100644 --- a/components/catalog/detail-hero.tsx +++ b/components/catalog/detail-hero.tsx @@ -59,14 +59,23 @@ function DetailHero({
{backdropUrl ? ( /* - * Bleeds to the edges of the main column and up to the navbar, so the - * artwork reads as the page's backdrop rather than a boxed image. The - * negative margins mirror `main`'s own padding in `app/layout.tsx`. + * Bleeds to the edges of the main column and up behind the navbar, so + * the artwork reads as the page's backdrop rather than a boxed image. + * The negative margins are `main`'s own padding in `app/layout.tsx` + * plus `--navbar-height`. */
+ {/* + * The fade into the page is a mask, not a colour laid over the + * image, so the artwork dissolves into whatever is behind it: the + * film strip background, which a fade to `--background` would cut + * off with a black band. Bottom third and left side per the spec; + * the right edge only fades once the column stops at its 1600px cap + * and the image would otherwise end in a hard line. + */} {/* - * The fade into the page, written with the `background` token so it - * always meets the real page colour. Bottom third and left side per - * the spec; the right edge only fades once the column stops at its - * 1600px cap and the image would otherwise end in a hard line. + * The image reaches up behind the navbar, so the bar sits on the + * artwork. The shade keeps its text and controls readable there. */} -