From 7aa2666eec0dacccf40054496aa5405dca283bf7 Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 11 Oct 2026 14:27:11 +0200 Subject: [PATCH 01/25] feat(background): add the film strip scene in hand written WebGL2 Coiled film strips and rising bokeh from the v2 Claude Design file, drawn in a worker where OffscreenCanvas allows and on the main thread otherwise, with the muted text contrast cap, reduced motion and hidden tab pause. Co-Authored-By: Claude Opus 5.5 --- lib/film-scene/budget.test.ts | 72 +++++++ lib/film-scene/budget.ts | 61 ++++++ lib/film-scene/clock.test.ts | 145 ++++++++++++++ lib/film-scene/clock.ts | 136 +++++++++++++ lib/film-scene/layout.test.ts | 132 +++++++++++++ lib/film-scene/layout.ts | 280 ++++++++++++++++++++++++++ lib/film-scene/matrices.ts | 125 ++++++++++++ lib/film-scene/messages.test.ts | 63 ++++++ lib/film-scene/messages.ts | 101 ++++++++++ lib/film-scene/palette.test.ts | 84 ++++++++ lib/film-scene/palette.ts | 116 +++++++++++ lib/film-scene/run.test.ts | 78 ++++++++ lib/film-scene/run.ts | 91 +++++++++ lib/film-scene/scene.test.ts | 85 ++++++++ lib/film-scene/scene.ts | 327 +++++++++++++++++++++++++++++++ lib/film-scene/shaders.ts | 219 +++++++++++++++++++++ lib/film-scene/start.ts | 255 ++++++++++++++++++++++++ lib/film-scene/worker.ts | 57 ++++++ prompts/film-strip-background.md | 164 ++++++++++++++++ 19 files changed, 2591 insertions(+) create mode 100644 lib/film-scene/budget.test.ts create mode 100644 lib/film-scene/budget.ts create mode 100644 lib/film-scene/clock.test.ts create mode 100644 lib/film-scene/clock.ts create mode 100644 lib/film-scene/layout.test.ts create mode 100644 lib/film-scene/layout.ts create mode 100644 lib/film-scene/matrices.ts create mode 100644 lib/film-scene/messages.test.ts create mode 100644 lib/film-scene/messages.ts create mode 100644 lib/film-scene/palette.test.ts create mode 100644 lib/film-scene/palette.ts create mode 100644 lib/film-scene/run.test.ts create mode 100644 lib/film-scene/run.ts create mode 100644 lib/film-scene/scene.test.ts create mode 100644 lib/film-scene/scene.ts create mode 100644 lib/film-scene/shaders.ts create mode 100644 lib/film-scene/start.ts create mode 100644 lib/film-scene/worker.ts create mode 100644 prompts/film-strip-background.md diff --git a/lib/film-scene/budget.test.ts b/lib/film-scene/budget.test.ts new file mode 100644 index 0000000..7d8fdce --- /dev/null +++ b/lib/film-scene/budget.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; + +import { + COMPACT_BUDGET, + chooseBudget, + FULL_BUDGET, + MAX_PIXEL_RATIO, + sameGeometry, +} from "./budget"; + +/** covers: prompts/film-strip-background.md, AC-9 */ +describe("chooseBudget", () => { + it("gives a wide fine pointer screen the design's full scene", () => { + const budget = chooseBudget({ + viewportWidth: 1440, + coarsePointer: false, + devicePixelRatio: 1, + }); + expect(budget).toEqual({ + strips: 3, + segments: 400, + bokeh: 70, + pixelRatio: 1, + }); + expect(budget).toMatchObject(FULL_BUDGET); + }); + + it.each([ + { viewportWidth: 390, coarsePointer: false }, + { viewportWidth: 767, coarsePointer: false }, + { viewportWidth: 1366, coarsePointer: true }, + ])("uses the compact scene for %o", (input) => { + expect(chooseBudget({ ...input, devicePixelRatio: 2 })).toMatchObject( + COMPACT_BUDGET, + ); + }); + + it("caps the pixel ratio at 1.5 and repairs a nonsense one", () => { + for (const devicePixelRatio of [2, 3, 1.5]) { + expect( + chooseBudget({ + viewportWidth: 1440, + coarsePointer: false, + devicePixelRatio, + }).pixelRatio, + ).toBe(MAX_PIXEL_RATIO); + } + expect( + chooseBudget({ + viewportWidth: 1440, + coarsePointer: false, + devicePixelRatio: 0, + }).pixelRatio, + ).toBe(1); + }); + + it("rebuilds only across the breakpoint, not for a pixel ratio change", () => { + const wide = (devicePixelRatio: number) => + chooseBudget({ + viewportWidth: 1440, + coarsePointer: false, + devicePixelRatio, + }); + const narrow = chooseBudget({ + viewportWidth: 600, + coarsePointer: false, + devicePixelRatio: 1, + }); + expect(sameGeometry(wide(1), wide(2))).toBe(true); + expect(sameGeometry(wide(1), narrow)).toBe(false); + }); +}); diff --git a/lib/film-scene/budget.ts b/lib/film-scene/budget.ts new file mode 100644 index 0000000..a3b15d2 --- /dev/null +++ b/lib/film-scene/budget.ts @@ -0,0 +1,61 @@ +/** + * How much scene a device gets (prompts/film-strip-background.md, decision 8). + * + * The design file drew three strips of 400 ribbon segments and 70 bokeh + * sprites at up to 2× pixel ratio. A phone shows less of the scene and pays + * more for every pixel, so it gets two strips, coarser ribbons and half the + * bokeh, which reads the same at that size. + */ +export interface SceneBudget { + strips: number; + segments: number; + bokeh: number; + pixelRatio: number; +} + +/** Below this width, or with a coarse pointer, the lighter scene is used. */ +export const COMPACT_BREAKPOINT = 768; + +/** The design used 2; 1.5 is visually identical for soft, fogged shapes. */ +export const MAX_PIXEL_RATIO = 1.5; + +/** The design file's own scene, for a wide screen with a fine pointer. */ +export const FULL_BUDGET = { strips: 3, segments: 400, bokeh: 70 } as const; + +/** Two strips, coarser ribbons and half the bokeh. */ +export const COMPACT_BUDGET = { strips: 2, segments: 240, bokeh: 35 } as const; + +/** + * Picks the budget from the viewport, never from a user agent string: a + * narrow desktop window is treated like a phone because it shows as little. + */ +export function chooseBudget({ + viewportWidth, + coarsePointer, + devicePixelRatio, +}: { + viewportWidth: number; + coarsePointer: boolean; + devicePixelRatio: number; +}): SceneBudget { + const compact = coarsePointer || viewportWidth < COMPACT_BREAKPOINT; + const ratio = + Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 + ? devicePixelRatio + : 1; + return { + ...(compact ? COMPACT_BUDGET : FULL_BUDGET), + pixelRatio: Math.min(ratio, MAX_PIXEL_RATIO), + }; +} + +/** + * Whether two budgets build the same geometry. The geometry is uploaded once, + * so a change here means a rebuild, while a pixel ratio change is only a + * canvas resize. + */ +export function sameGeometry(a: SceneBudget, b: SceneBudget): boolean { + return ( + a.strips === b.strips && a.segments === b.segments && a.bokeh === b.bokeh + ); +} diff --git a/lib/film-scene/clock.test.ts b/lib/film-scene/clock.test.ts new file mode 100644 index 0000000..e822df2 --- /dev/null +++ b/lib/film-scene/clock.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createClock, MAX_STEP_SECONDS, SCENE_SPEED } from "./clock"; +import type { FrameState, SceneRenderer } from "./scene"; + +/** A renderer that records frames, and a scheduler the test steps by hand. */ +function harness({ reducedMotion = false, visible = true } = {}) { + const frames: FrameState[] = []; + const renderer: SceneRenderer = { + draw: (frame) => frames.push({ ...frame }), + resize: vi.fn(), + dispose: vi.fn(), + }; + let pending: ((now: number) => void) | null = null; + const scheduler = { + request: vi.fn((callback: (now: number) => void) => { + pending = callback; + return 1; + }), + cancel: vi.fn(() => { + pending = null; + }), + }; + const onFirstFrame = vi.fn(); + const clock = createClock({ + renderer, + scheduler, + reducedMotion, + visible, + onFirstFrame, + }); + const step = (now: number) => { + const callback = pending; + pending = null; + callback?.(now); + }; + return { + clock, + frames, + renderer, + scheduler, + onFirstFrame, + step, + pending: () => pending, + }; +} + +/** covers: prompts/film-strip-background.md, AC-6, AC-7 */ +describe("the scene clock", () => { + it("does nothing until it has a size", () => { + const h = harness(); + expect(h.scheduler.request).not.toHaveBeenCalled(); + h.clock.resize(100, 100, 1); + expect(h.scheduler.request).toHaveBeenCalledTimes(1); + }); + + it("advances by real elapsed time, capped per frame", () => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.step(1000); + h.step(1016); + h.step(3000); + const times = h.frames.map((f) => f.time); + expect(times[0]).toBe(0); + expect(times[1]).toBeCloseTo(0.016 * SCENE_SPEED); + expect(times[2]).toBeCloseTo((0.016 + MAX_STEP_SECONDS) * SCENE_SPEED); + expect(h.onFirstFrame).toHaveBeenCalledTimes(1); + }); + + it("requests no frames while hidden and resumes without a jump", () => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.step(0); + h.step(16); + h.clock.setVisible(false); + expect(h.pending()).toBeNull(); + expect(h.scheduler.cancel).toHaveBeenCalled(); + + h.clock.setVisible(true); + h.step(60_000); + h.step(60_016); + const times = h.frames.map((f) => f.time); + expect(times.at(-2)).toBeCloseTo(0.016 * SCENE_SPEED); + expect(times.at(-1)).toBeCloseTo(0.032 * SCENE_SPEED); + }); + + it("draws one still frame under reduced motion, centred", () => { + const h = harness({ reducedMotion: true }); + h.clock.setPointer(0.5, 0.5); + h.clock.resize(100, 100, 1); + expect(h.frames).toEqual([{ time: 0, pointerX: 0, pointerY: 0 }]); + expect(h.pending()).toBeNull(); + + // A resize clears the canvas, so the still frame is drawn again. + h.clock.resize(200, 100, 1); + expect(h.frames).toHaveLength(2); + expect(h.pending()).toBeNull(); + }); + + it("stops when reduced motion turns on and starts when it turns off", () => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.step(0); + h.clock.setReducedMotion(true); + expect(h.pending()).toBeNull(); + h.clock.setReducedMotion(false); + expect(h.pending()).not.toBeNull(); + }); + + it("eases the camera toward the pointer", () => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.clock.setPointer(0.5, -0.5); + h.step(0); + for (let now = 16; now < 5000; now += 16) h.step(now); + const last = h.frames.at(-1); + expect(last?.pointerX).toBeCloseTo(0.5, 2); + expect(last?.pointerY).toBeCloseTo(-0.5, 2); + }); + + it("eases the same in real time at 60 and at 120 frames per second", () => { + const run = (fps: number) => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.clock.setPointer(0.5, 0); + // Half a second, with frames short enough to stay under the step cap. + for (let frame = 0; frame <= fps / 2; frame++) + h.step((frame * 1000) / fps); + return h.frames.at(-1)?.pointerX ?? 0; + }; + expect(run(120)).toBeCloseTo(run(60), 3); + }); + + it("disposes the renderer once and never draws again", () => { + const h = harness(); + h.clock.resize(100, 100, 1); + h.clock.dispose(); + h.clock.dispose(); + h.clock.resize(10, 10, 1); + h.clock.setVisible(true); + expect(h.renderer.dispose).toHaveBeenCalledTimes(1); + expect(h.pending()).toBeNull(); + expect(h.frames).toHaveLength(0); + }); +}); diff --git a/lib/film-scene/clock.ts b/lib/film-scene/clock.ts new file mode 100644 index 0000000..40b9df5 --- /dev/null +++ b/lib/film-scene/clock.ts @@ -0,0 +1,136 @@ +import type { SceneRenderer } from "./scene"; + +/** `requestAnimationFrame` where it exists, injected so tests can step it. */ +export interface FrameScheduler { + request(callback: (now: number) => void): number; + cancel(handle: number): void; +} + +/** + * The running clock as its owner sees it: the page's events in, nothing out. + * The worker and the main thread fallback drive it through the same methods. + */ +export interface SceneClock { + resize(width: number, height: number, pixelRatio: number): void; + setPointer(x: number, y: number): void; + setVisible(visible: boolean): void; + setReducedMotion(reduced: boolean): void; + dispose(): void; +} + +/** The design capped a frame's step at 50ms, so a stall never lurches. */ +export const MAX_STEP_SECONDS = 0.05; + +/** The design's `speed` prop: scene seconds per real second. */ +export const SCENE_SPEED = 0.25; + +/** The design's pointer easing, 2% per frame at 60 frames per second. */ +export const POINTER_EASE = 0.02; + +/** + * The animation clock (prompts/film-strip-background.md, decisions 8 and 9). + * + * It runs only while the page is visible and motion is allowed. A hidden tab + * requests no frames at all, and coming back resumes from where the clock + * stopped rather than jumping by the time spent away. Under reduced motion it + * draws a single still frame with the camera centred, and redraws only when + * the size changes, because a permanently moving page is what that setting + * exists to prevent (the `skeleton-pulse` precedent in `globals.css`). + * + * The pointer eases in real time rather than per frame, so the parallax + * feels the same on a 120 Hz screen as on a 60 Hz one. + * + * `onFirstFrame` fires once, after the first draw, so the canvas can fade in + * over a real picture rather than an empty one. + */ +export function createClock({ + renderer, + scheduler, + reducedMotion, + visible, + onFirstFrame, +}: { + renderer: SceneRenderer; + scheduler: FrameScheduler; + reducedMotion: boolean; + visible: boolean; + onFirstFrame?: () => void; +}): SceneClock { + let time = 0; + let last: number | null = null; + let handle: number | null = null; + let sized = false; + let disposed = false; + let firstDrawn = false; + const pointer = { x: 0, y: 0, targetX: 0, targetY: 0 }; + + const draw = () => { + renderer.draw({ time, pointerX: pointer.x, pointerY: pointer.y }); + if (!firstDrawn) { + firstDrawn = true; + onFirstFrame?.(); + } + }; + + const tick = (now: number) => { + handle = null; + const step = + last === null ? 0 : Math.min((now - last) / 1000, MAX_STEP_SECONDS); + last = now; + time += step * SCENE_SPEED; + const ease = 1 - (1 - POINTER_EASE) ** (step * 60); + pointer.x += (pointer.targetX - pointer.x) * ease; + pointer.y += (pointer.targetY - pointer.y) * ease; + draw(); + handle = scheduler.request(tick); + }; + + const animating = () => sized && visible && !reducedMotion && !disposed; + + const stop = () => { + if (handle !== null) scheduler.cancel(handle); + handle = null; + last = null; + }; + + const update = () => { + if (animating()) { + if (handle === null) handle = scheduler.request(tick); + return; + } + stop(); + if (sized && visible && reducedMotion && !disposed) { + pointer.x = pointer.y = pointer.targetX = pointer.targetY = 0; + draw(); + } + }; + + return { + resize(width, height, pixelRatio) { + if (disposed) return; + renderer.resize(width, height, pixelRatio); + sized = true; + // A resize clears the drawing buffer, so a still frame is redrawn now; + // a running clock repaints on its next tick anyway. + update(); + }, + setPointer(x, y) { + pointer.targetX = x; + pointer.targetY = y; + }, + setVisible(next) { + visible = next; + update(); + }, + setReducedMotion(next) { + reducedMotion = next; + update(); + }, + dispose() { + if (disposed) return; + disposed = true; + stop(); + renderer.dispose(); + }, + }; +} diff --git a/lib/film-scene/layout.test.ts b/lib/film-scene/layout.test.ts new file mode 100644 index 0000000..65a2fa4 --- /dev/null +++ b/lib/film-scene/layout.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; + +import { + BOKEH_STRIDE, + bokehInstances, + CAMERA, + FILM_WIDTH, + FRAME_LENGTH, + placeStrip, + RIBBON_STRIDE, + ribbon, + STRIPS, + seededRandom, +} from "./layout"; + +const [first] = STRIPS; + +/** covers: prompts/film-strip-background.md, AC-13 */ +describe("ribbon", () => { + const segments = 40; + const { vertices, indices } = ribbon(first, segments); + const vertex = (i: number) => + Array.from(vertices.subarray(i * RIBBON_STRIDE, (i + 1) * RIBBON_STRIDE)); + + it("has two vertices per step and two triangles between steps", () => { + expect(vertices.length).toBe((segments + 1) * 2 * RIBBON_STRIDE); + expect(indices.length).toBe(segments * 6); + expect(Math.max(...indices)).toBe((segments + 1) * 2 - 1); + }); + + it("is the film's width across and level, with unit normals", () => { + for (const step of [0, 13, segments]) { + const left = vertex(step * 2); + const right = vertex(step * 2 + 1); + const across = Math.hypot( + right[0] - left[0], + right[1] - left[1], + right[2] - left[2], + ); + expect(across).toBeCloseTo(FILM_WIDTH, 5); + expect(Math.hypot(left[3], left[4], left[5])).toBeCloseTo(1, 5); + expect([left[7], right[7]]).toEqual([0, 1]); + } + }); + + it("spaces its steps evenly along the coil, one u per frame", () => { + const u = (step: number) => vertex(step * 2)[6]; + const stepU = u(1) - u(0); + for (let step = 1; step < segments; step++) { + expect(u(step + 1) - u(step)).toBeCloseTo(stepU, 2); + } + expect(u(0)).toBe(0); + // A coil of a 2.4 radius and 1.4 turns is many frames long. + expect(u(segments) * FRAME_LENGTH).toBeGreaterThan(2 * Math.PI * 2.4); + }); + + it("stays within the coil's swollen radius, centred vertically", () => { + const limit = first.radius * 1.25 + FILM_WIDTH; + for (let i = 0; i < vertices.length; i += RIBBON_STRIDE) { + expect(Math.hypot(vertices[i], vertices[i + 2])).toBeLessThan(limit); + expect(Math.abs(vertices[i + 1])).toBeLessThan( + (first.turns * first.pitch) / 2 + FILM_WIDTH, + ); + } + }); +}); + +describe("placeStrip", () => { + it("keeps the same fraction of the frustum on any screen shape", () => { + const halfHeight = Math.tan(CAMERA.fovY / 2) * (CAMERA.z - first.z); + for (const aspect of [16 / 9, 9 / 16]) { + const { x, y } = placeStrip(first, aspect); + expect(x / (halfHeight * aspect)).toBeCloseTo(first.fx); + expect(y / halfHeight).toBeCloseTo(first.fy); + } + }); + + it("puts the design's strips upper right, lower left and bottom", () => { + const [a, b, c] = STRIPS.map((strip) => placeStrip(strip, 16 / 9)); + expect(a.x > 0 && a.y > 0).toBe(true); + expect(b.x < 0 && b.y < 0).toBe(true); + expect(c.y < 0).toBe(true); + }); +}); + +describe("bokehInstances", () => { + it("is the same field for the same seed", () => { + expect(bokehInstances(70)).toEqual(bokehInstances(70)); + expect(bokehInstances(70, 1)).not.toEqual(bokehInstances(70, 2)); + }); + + it("draws every disc inside the design's ranges", () => { + const data = bokehInstances(200); + for (let k = 0; k < 200; k++) { + const [x, y, z, size, rise, drift, base, phase, tint] = data.subarray( + k * BOKEH_STRIDE, + (k + 1) * BOKEH_STRIDE, + ); + expect(Math.abs(x)).toBeLessThanOrEqual(11); + expect(Math.abs(y)).toBeLessThanOrEqual(7); + expect(z).toBeLessThanOrEqual(3); + expect(z).toBeGreaterThanOrEqual(-11); + expect(size).toBeGreaterThan(0.06 - 1e-6); + expect(size).toBeLessThan(2.0); + expect(rise).toBeGreaterThanOrEqual(0.05 - 1e-6); + expect(Math.abs(drift)).toBeLessThanOrEqual(0.03); + expect(base).toBeGreaterThan(0.1); + expect(base).toBeLessThan(0.91); + expect(phase).toBeLessThan(Math.PI * 2); + expect(tint).toBe(k % 4); + } + }); + + it("keeps the design's mix of large faint and small bright discs", () => { + const data = bokehInstances(1000); + let big = 0; + for (let k = 0; k < 1000; k++) if (data[k * BOKEH_STRIDE + 3] >= 0.6) big++; + expect(big / 1000).toBeGreaterThan(0.2); + expect(big / 1000).toBeLessThan(0.4); + }); +}); + +describe("seededRandom", () => { + it("returns values in 0 to 1", () => { + const random = seededRandom(7); + for (let i = 0; i < 1000; i++) { + const value = random(); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); diff --git a/lib/film-scene/layout.ts b/lib/film-scene/layout.ts new file mode 100644 index 0000000..8d0857a --- /dev/null +++ b/lib/film-scene/layout.ts @@ -0,0 +1,280 @@ +/** + * The scene's geometry and placement, as pure functions + * (prompts/film-strip-background.md, decisions 1 and 8). Everything here is + * the design file's numbers, computed once at build time and uploaded, so the + * frame loop only moves whole strips and passes the clock to the shaders. + */ + +/** One strip of the design's layout table. */ +export interface StripLayout { + /** Helix radius, turns and rise per turn. */ + radius: number; + turns: number; + pitch: number; + /** Screen position as a fraction of the half frustum at the strip's depth. */ + fx: number; + fy: number; + z: number; + /** Tilt of the whole coil, in radians. */ + rx: number; + rz: number; + scale: number; + /** Spin around the coil's axis and film scroll, per scene second. */ + spin: number; + run: number; +} + +/** + * The design's strips, in its order: the large coil upper right, the one + * lower left, then a small distant one at the bottom. A fourth existed in the + * design's table for a prop the app does not expose, so it is not kept. + */ +export const STRIPS: readonly StripLayout[] = [ + { + radius: 2.4, + turns: 1.4, + pitch: 1.7, + fx: 0.62, + fy: 0.62, + z: -1, + rx: 0.35, + rz: -0.55, + scale: 1, + spin: 0.12, + run: 0.12, + }, + { + radius: 2.0, + turns: 1.2, + pitch: 1.6, + fx: -0.68, + fy: -0.6, + z: 0, + rx: -0.25, + rz: 0.5, + scale: 1, + spin: -0.1, + run: -0.1, + }, + { + radius: 1.8, + turns: 1.6, + pitch: 1.6, + fx: 0.15, + fy: -0.95, + z: -7, + rx: 0.5, + rz: 1.2, + scale: 0.9, + spin: 0.08, + run: 0.08, + }, +]; + +/** The film's width, and the length of one frame along it. */ +export const FILM_WIDTH = 1.1; +export const FRAME_LENGTH = FILM_WIDTH * 1.4; + +/** The design's camera: a 50 degree lens at z = 10 looking at (0, 0, -3). */ +export const CAMERA = { + fovY: (50 * Math.PI) / 180, + near: 0.1, + far: 100, + z: 10, + target: [0, 0, -3] as const, + /** How far the pointer swings the eye, for a pointer at an edge. */ + parallaxX: 0.6, + parallaxY: 0.4, +} as const; + +type Vec3 = [number, number, number]; + +/** + * The design's coil at `s` from 0 to 1: a helix whose radius swells by a + * quarter in the middle, centred on the origin vertically. The design ran a + * Catmull-Rom spline through 61 samples of this; the curve itself is used + * here, which is the same shape without the spline's tiny wobble. + */ +function coilPoint(strip: StripLayout, s: number): Vec3 { + const angle = s * strip.turns * Math.PI * 2; + const r = strip.radius * (1 + 0.25 * Math.sin(s * Math.PI)); + return [ + r * Math.cos(angle), + (angle / (Math.PI * 2)) * strip.pitch - (strip.turns * strip.pitch) / 2, + r * Math.sin(angle), + ]; +} + +/** Floats per ribbon vertex: position, normal, uv. */ +export const RIBBON_STRIDE = 8; + +/** Fine steps used to measure the coil's length before spacing the ribbon. */ +const ARC_SAMPLES = 2048; + +/** + * The film ribbon along one strip's coil, as an indexed triangle strip laid + * out for `gl.TRIANGLES`. Vertices are spaced evenly by arc length, as the + * design's `getPointAt` did, so the frames along the film are all the same + * size. The ribbon stays level, its width at right angles to the coil and to + * world up, which is what made the design's film twist as it coils. `u` runs + * one unit per frame along the film, `v` 0 to 1 across it. + */ +export function ribbon( + strip: StripLayout, + segments: number, +): { vertices: Float32Array; indices: Uint16Array } { + // Cumulative length at each fine sample. + const lengths = new Float64Array(ARC_SAMPLES + 1); + let previous = coilPoint(strip, 0); + for (let i = 1; i <= ARC_SAMPLES; i++) { + const point = coilPoint(strip, i / ARC_SAMPLES); + lengths[i] = + lengths[i - 1] + + Math.hypot( + point[0] - previous[0], + point[1] - previous[1], + point[2] - previous[2], + ); + previous = point; + } + const total = lengths[ARC_SAMPLES]; + + // The coil parameter at a given distance along it. + let cursor = 0; + const atLength = (target: number) => { + while (cursor < ARC_SAMPLES - 1 && lengths[cursor + 1] < target) cursor++; + const span = lengths[cursor + 1] - lengths[cursor] || 1; + return (cursor + (target - lengths[cursor]) / span) / ARC_SAMPLES; + }; + + const half = FILM_WIDTH / 2; + const vertices = new Float32Array((segments + 1) * 2 * RIBBON_STRIDE); + const indices = new Uint16Array(segments * 6); + const delta = 1 / ARC_SAMPLES; + + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const s = atLength(t * total); + const p = coilPoint(strip, s); + const a = coilPoint(strip, Math.max(0, s - delta)); + const b = coilPoint(strip, Math.min(1, s + delta)); + const tangent = normalize([b[0] - a[0], b[1] - a[1], b[2] - a[2]]); + // World up with its component along the coil removed. + const side = normalize([ + -tangent[1] * tangent[0], + 1 - tangent[1] * tangent[1], + -tangent[1] * tangent[2], + ]); + const normal = normalize(cross(side, tangent)); + const u = (t * total) / FRAME_LENGTH; + + for (const [sign, v] of [ + [-1, 0], + [1, 1], + ] as const) { + const offset = (i * 2 + v) * RIBBON_STRIDE; + vertices.set( + [ + p[0] + sign * side[0] * half, + p[1] + sign * side[1] * half, + p[2] + sign * side[2] * half, + ...normal, + u, + v, + ], + offset, + ); + } + if (i < segments) { + const first = i * 2; + indices.set( + [first, first + 1, first + 2, first + 1, first + 3, first + 2], + i * 6, + ); + } + } + return { vertices, indices }; +} + +/** + * Where a strip sits for a viewport of this aspect ratio: the same fraction + * of the visible half width and height at its depth, so a coil stays in its + * corner on any screen shape, as the design placed it. + */ +export function placeStrip( + strip: StripLayout, + aspect: number, +): { x: number; y: number } { + const distance = CAMERA.z - strip.z; + const halfHeight = Math.tan(CAMERA.fovY / 2) * distance; + return { + x: strip.fx * halfHeight * aspect, + y: strip.fy * halfHeight, + }; +} + +/** + * Floats per bokeh instance: start position and size, rise, drift, opacity + * and phase, and which of the four tints it takes. + */ +export const BOKEH_STRIDE = 9; + +/** The design's bokeh field: 22 wide, rising through 16, from z 3 to -11. */ +export const BOKEH_FIELD = { width: 22, height: 16 } as const; + +/** + * A small seeded generator (mulberry32), so the bokeh field is the same on + * every load and in tests, rather than reshuffling on each mount. + */ +export function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** The seed the bokeh field is drawn from. */ +export const BOKEH_SEED = 20261011; + +/** + * The design's bokeh, drawn the way it drew them: three in ten large, faint + * discs and the rest small bright ones, tinted in turn. Where each one is at + * a given time is worked out in the vertex shader from these. + */ +export function bokehInstances(count: number, seed = BOKEH_SEED): Float32Array { + const random = seededRandom(seed); + const data = new Float32Array(count * BOKEH_STRIDE); + for (let k = 0; k < count; k++) { + const big = random() < 0.3; + const size = big ? 0.6 + random() * 1.4 : 0.06 + random() * 0.35; + const x = (random() - 0.5) * BOKEH_FIELD.width; + const y = (random() - 0.5) * 14; + const z = -random() * 14 + 3; + const base = big ? 0.12 + random() * 0.2 : 0.35 + random() * 0.55; + const phase = random() * Math.PI * 2; + const rise = 0.05 + random() * 0.15; + const drift = (random() - 0.5) * 0.06; + data.set( + [x, y, z, size, rise, drift, base, phase, k % 4], + k * BOKEH_STRIDE, + ); + } + return data; +} + +function normalize([x, y, z]: Vec3): Vec3 { + const length = Math.hypot(x, y, z) || 1; + return [x / length, y / length, z / length]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} diff --git a/lib/film-scene/matrices.ts b/lib/film-scene/matrices.ts new file mode 100644 index 0000000..debe626 --- /dev/null +++ b/lib/film-scene/matrices.ts @@ -0,0 +1,125 @@ +/** + * The few matrices the scene needs, column major as WebGL expects. Written + * out rather than imported: they are the only linear algebra the background + * uses, and a maths library would cost more than the renderer. + */ + +type Vec3 = readonly [number, number, number]; +/** A 3 × 3 matrix, row major, used only while composing. */ +type Mat3 = readonly number[]; + +/** A perspective projection, `fovY` in radians. */ +export function perspective( + fovY: number, + aspect: number, + near: number, + far: number, +): Float32Array { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + // biome-ignore format: matrix layout + return new Float32Array([ + f / aspect, 0, 0, 0, + 0, f, 0, 0, + 0, 0, (far + near) * nf, -1, + 0, 0, 2 * far * near * nf, 0, + ]); +} + +function normalize([x, y, z]: Vec3): Vec3 { + const length = Math.hypot(x, y, z) || 1; + return [x / length, y / length, z / length]; +} + +function cross(a: Vec3, b: Vec3): Vec3 { + return [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ]; +} + +function dot(a: Vec3, b: Vec3): number { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +/** The view matrix of a camera at `eye` looking at `target`, y up. */ +export function lookAt(eye: Vec3, target: Vec3): Float32Array { + const z = normalize([ + eye[0] - target[0], + eye[1] - target[1], + eye[2] - target[2], + ]); + const x = normalize(cross([0, 1, 0], z)); + const y = cross(z, x); + // biome-ignore format: matrix layout + return new Float32Array([ + x[0], y[0], z[0], 0, + x[1], y[1], z[1], 0, + x[2], y[2], z[2], 0, + -dot(x, eye), -dot(y, eye), -dot(z, eye), 1, + ]); +} + +function multiply(a: Mat3, b: Mat3): Mat3 { + const out: number[] = []; + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 3; col++) { + out.push( + a[row * 3] * b[col] + + a[row * 3 + 1] * b[3 + col] + + a[row * 3 + 2] * b[6 + col], + ); + } + } + return out; +} + +/** + * One strip's model matrix, composed the way the design nested it: the coil + * spins about its own axis inside a group that is scaled, tilted (Three's + * XYZ order, with no Y turn) and then moved into place. + */ +export function stripModel({ + x, + y, + z, + rx, + rz, + scale, + spin, +}: { + x: number; + y: number; + z: number; + rx: number; + rz: number; + scale: number; + spin: number; +}): Float32Array { + const [cx, sx] = [Math.cos(rx), Math.sin(rx)]; + const [cz, sz] = [Math.cos(rz), Math.sin(rz)]; + const [cy, sy] = [Math.cos(spin), Math.sin(spin)]; + const tilt = multiply( + [1, 0, 0, 0, cx, -sx, 0, sx, cx], + [cz, -sz, 0, sz, cz, 0, 0, 0, 1], + ); + const m = multiply(tilt, [ + cy * scale, + 0, + sy * scale, + 0, + scale, + 0, + -sy * scale, + 0, + cy * scale, + ]); + // biome-ignore format: matrix layout + return new Float32Array([ + m[0], m[3], m[6], 0, + m[1], m[4], m[7], 0, + m[2], m[5], m[8], 0, + x, y, z, 1, + ]); +} diff --git a/lib/film-scene/messages.test.ts b/lib/film-scene/messages.test.ts new file mode 100644 index 0000000..f3d2096 --- /dev/null +++ b/lib/film-scene/messages.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { toWorkerSchema } from "./messages"; + +describe("worker messages", () => { + it.each([ + { type: "resize", width: 1440, height: 900, pixelRatio: 1.5 }, + { type: "pointer", x: -0.5, y: 0.25 }, + { type: "visible", visible: false }, + { type: "motion", reduced: true }, + ])("accepts %o", (message) => { + expect(toWorkerSchema.safeParse(message).success).toBe(true); + }); + + it.each([ + { type: "pointer", x: 0.8, y: 0 }, + { type: "resize", width: -1, height: 900, pixelRatio: 1 }, + { type: "resize", width: 1, height: 900, pixelRatio: 99 }, + { type: "visible", visible: "yes" }, + { type: "unknown" }, + { type: "dispose" }, + null, + "dispose", + ])("rejects %o", (message) => { + expect(toWorkerSchema.safeParse(message).success).toBe(false); + }); + + it("rejects an init whose canvas is not an OffscreenCanvas", () => { + const rgb = [0.1, 0.2, 0.3]; + const palette = Object.fromEntries( + [ + "glow", + "fog", + "filmBase", + "filmWindow", + "filmShadow", + "filmEdge", + "filmRail", + "filmGlow", + "lightAmbient", + "lightKey", + "lightRim", + "bokeh1", + "bokeh2", + "bokeh3", + "bokeh4", + "text", + ].map((key) => [key, rgb]), + ); + expect( + toWorkerSchema.safeParse({ + type: "init", + canvas: {}, + width: 10, + height: 10, + budget: { strips: 3, segments: 400, bokeh: 70, pixelRatio: 1 }, + palette, + reducedMotion: false, + visible: true, + }).success, + ).toBe(false); + }); +}); diff --git a/lib/film-scene/messages.ts b/lib/film-scene/messages.ts new file mode 100644 index 0000000..e4fd039 --- /dev/null +++ b/lib/film-scene/messages.ts @@ -0,0 +1,101 @@ +import * as z from "zod/mini"; + +import type { SceneBudget } from "./budget"; +import type { ScenePalette } from "./palette"; + +/** + * The messages between the page and the background worker + * (prompts/film-strip-background.md, decision 7). + * + * `zod/mini` rather than `zod`: the worker is its own chunk, and the full + * build would be most of its size. Only our own island posts to the worker, + * but the worker still validates every message, as AGENTS.md section 6 asks + * of every structured input. + */ + +const unit = z.number().check(z.gte(0), z.lte(1)); +const rgb = z.tuple([unit, unit, unit]); +const size = z.number().check(z.gte(0), z.lte(100_000)); +const pixelRatio = z.number().check(z.gt(0), z.lte(4)); +const count = (max: number) => z.number().check(z.int(), z.gte(0), z.lte(max)); + +// Each schema is typed as the shape it validates, so a field added to +// `SceneBudget` or `ScenePalette` and missed here fails the type check. + +/** A `SceneBudget` as it crosses to the worker in `init`. */ +const budgetSchema: z.ZodMiniType = z.object({ + strips: count(3), + // Two vertices per step, and the indices are 16 bit. + segments: count(10_000), + bokeh: count(1_000), + pixelRatio, +}); + +const paletteSchema: z.ZodMiniType = z.object({ + glow: rgb, + fog: rgb, + filmBase: rgb, + filmWindow: rgb, + filmShadow: rgb, + filmEdge: rgb, + filmRail: rgb, + filmGlow: rgb, + lightAmbient: rgb, + lightKey: rgb, + lightRim: rgb, + bokeh1: rgb, + bokeh2: rgb, + bokeh3: rgb, + bokeh4: rgb, + text: rgb, +}); + +const offscreenCanvas = z.custom( + (value) => + typeof OffscreenCanvas !== "undefined" && value instanceof OffscreenCanvas, +); + +/** + * Every message the page may post to the worker. The worker parses each one + * against this and drops anything else. + */ +export const toWorkerSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("init"), + canvas: offscreenCanvas, + width: size, + height: size, + budget: budgetSchema, + palette: paletteSchema, + reducedMotion: z.boolean(), + visible: z.boolean(), + }), + z.object({ + type: z.literal("resize"), + width: size, + height: size, + pixelRatio, + }), + z.object({ + type: z.literal("pointer"), + x: z.number().check(z.gte(-0.5), z.lte(0.5)), + y: z.number().check(z.gte(-0.5), z.lte(0.5)), + }), + z.object({ type: z.literal("visible"), visible: z.boolean() }), + z.object({ type: z.literal("motion"), reduced: z.boolean() }), +]); + +/** A message the page posts to the worker, checked by `toWorkerSchema`. */ +export type ToWorker = z.infer; + +/** + * The worker's replies. `failed` means it could never draw (Safari 16 has + * `OffscreenCanvas` without WebGL on it), so the page tries the main thread. + * `lost` means it drew and then lost its context, and the page gives up on + * the scene (decision 7): whatever took the GPU away would take it from the + * main thread too. + */ +export type FromWorker = + | { type: "ready" } + | { type: "failed" } + | { type: "lost" }; diff --git a/lib/film-scene/palette.test.ts b/lib/film-scene/palette.test.ts new file mode 100644 index 0000000..2793711 --- /dev/null +++ b/lib/film-scene/palette.test.ts @@ -0,0 +1,84 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +import { + channelsFromComputed, + peakLuminance, + relativeLuminance, + SCENE_TOKENS, + TEXT_CONTRAST, +} from "./palette"; + +const CSS = readFileSync("app/globals.css", "utf8"); + +/** The hex value a token is declared with in globals.css. */ +function declared(token: string): [number, number, number] { + const match = CSS.match(new RegExp(`${token}:\\s*#([0-9a-f]{6})`, "i")); + if (!match) throw new Error(`${token} is not a hex token`); + const hex = match[1]; + return [0, 2, 4].map( + (i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255, + ) as [number, number, number]; +} + +/** covers: prompts/film-strip-background.md, AC-3 */ +describe("channelsFromComputed", () => { + it("reads three channels from 0 to 255 as 0 to 1", () => { + expect(channelsFromComputed("14, 106, 128")).toEqual([ + 14 / 255, + 106 / 255, + 128 / 255, + ]); + }); + + it("ignores an alpha channel", () => { + expect(channelsFromComputed("0 0 0 / 0.5")).toEqual([0, 0, 0]); + }); + + it("rejects fewer than three channels or values out of range", () => { + expect(channelsFromComputed("")).toBeNull(); + expect(channelsFromComputed("12, 40")).toBeNull(); + expect(channelsFromComputed("300, 0, 0")).toBeNull(); + }); +}); + +describe("scene tokens", () => { + it("are all declared in globals.css", () => { + for (const token of Object.values(SCENE_TOKENS)) { + expect(CSS).toMatch(new RegExp(`${token}:`)); + } + }); + + it("no longer borrow the personal score cyan", () => { + expect(Object.values(SCENE_TOKENS)).not.toContain("--color-score-personal"); + }); +}); + +/** covers: prompts/film-strip-background.md, AC-11 */ +describe("peakLuminance", () => { + const contrast = (a: number, b: number) => + (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + const muted = declared("--muted-foreground"); + + it("keeps the muted text at 4.5:1 over the brightest allowed pixel", () => { + const text = relativeLuminance(muted); + expect(contrast(text, peakLuminance(muted))).toBeGreaterThanOrEqual( + TEXT_CONTRAST, + ); + }); + + it("leaves room above the gradient's brightest colour for the bokeh", () => { + expect(relativeLuminance(declared("--color-backdrop-glow"))).toBeLessThan( + peakLuminance(muted), + ); + }); + + it("allows no light at all behind text too dark to reach 4.5:1 on black", () => { + expect(peakLuminance([0.3, 0.3, 0.3])).toBe(0); + }); + + it("measures black and white as WCAG does", () => { + expect(relativeLuminance([0, 0, 0])).toBe(0); + expect(relativeLuminance([1, 1, 1])).toBeCloseTo(1); + }); +}); diff --git a/lib/film-scene/palette.ts b/lib/film-scene/palette.ts new file mode 100644 index 0000000..7c22fe0 --- /dev/null +++ b/lib/film-scene/palette.ts @@ -0,0 +1,116 @@ +/** An sRGB colour as three channels from 0 to 1. */ +export type Rgb = [number, number, number]; + +/** + * The colours the scene is lit with, read from `globals.css` tokens by the + * page (prompts/film-strip-background.md, decision 3), never written in a + * shader. They stay sRGB values, unconverted, because the design's Three.js + * r149 renderer did its lighting on them that way and wrote the result as is. + */ +export interface ScenePalette { + /** The gradient's brightest colour, the most light can ever sit on. */ + glow: Rgb; + /** The fog the distance fades into, the gradient's deep teal. */ + fog: Rgb; + filmBase: Rgb; + filmWindow: Rgb; + filmShadow: Rgb; + filmEdge: Rgb; + filmRail: Rgb; + filmGlow: Rgb; + lightAmbient: Rgb; + lightKey: Rgb; + lightRim: Rgb; + bokeh1: Rgb; + bokeh2: Rgb; + bokeh3: Rgb; + bokeh4: Rgb; + /** The muted body text the scene must stay legible under (AC-11). */ + text: Rgb; +} + +/** The token each colour is read from, all in `app/globals.css`. */ +export const SCENE_TOKENS = { + glow: "--color-backdrop-glow", + fog: "--color-backdrop-deep", + filmBase: "--color-film-base", + filmWindow: "--color-film-window", + filmShadow: "--color-film-shadow", + filmEdge: "--color-film-edge", + filmRail: "--color-film-rail", + filmGlow: "--color-film-glow", + lightAmbient: "--color-light-ambient", + lightKey: "--color-light-key", + lightRim: "--color-light-rim", + bokeh1: "--color-bokeh-1", + bokeh2: "--color-bokeh-2", + bokeh3: "--color-bokeh-3", + bokeh4: "--color-bokeh-4", + text: "--muted-foreground", +} as const satisfies Record; + +/** + * The channels of a computed CSS colour as 0 to 1. Browsers serialise a + * computed sRGB colour as three numbers from 0 to 255 inside the function + * notation, so the numbers are all that is read. Null when there are not + * three of them in range. + */ +export function channelsFromComputed(value: string): Rgb | null { + const numbers = value + .match(/-?\d*\.?\d+/g) + ?.slice(0, 3) + .map(Number); + if (numbers?.length !== 3) return null; + if (numbers.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { + return null; + } + const [r, g, b] = numbers.map((n) => n / 255); + return [r, g, b]; +} + +/** + * Resolves each token through the cascade on `probe`, so a token defined as + * another token, or changed in `globals.css`, still comes through. Null when + * any value cannot be read, and the page then keeps its CSS gradient. + */ +export function readScenePalette(probe: HTMLElement): ScenePalette | null { + const previous = probe.style.color; + const palette: Partial = {}; + let complete = true; + for (const [key, token] of Object.entries(SCENE_TOKENS)) { + probe.style.color = `var(${token})`; + const channels = channelsFromComputed(getComputedStyle(probe).color); + if (!channels) complete = false; + else palette[key as keyof ScenePalette] = channels; + } + probe.style.color = previous; + return complete ? (palette as ScenePalette) : null; +} + +/** WCAG 2 relative luminance of an sRGB colour. */ +export function relativeLuminance([r, g, b]: Rgb): number { + const linear = (c: number) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b); +} + +/** The minimum contrast AC-11 asks of muted text over the background. */ +export const TEXT_CONTRAST = 4.5; + +/** + * Keeps the shader's estimate under the true ceiling: it approximates the + * sRGB curve with a 2.2 power, and output is rounded to 8 bits. + */ +const PEAK_MARGIN = 0.85; + +/** + * The brightest relative luminance any background pixel may reach while + * `text` keeps 4.5:1 over it (prompts/film-strip-background.md, decision 5). + * Both shaders cap their fragments to it, the bokeh counting the gradient + * they add onto. Read from the token, so a lighter or darker muted text + * moves the cap with it. + */ +export function peakLuminance(text: Rgb): number { + const ceiling = (relativeLuminance(text) + 0.05) / TEXT_CONTRAST - 0.05; + return Math.max(0, ceiling * PEAK_MARGIN); +} diff --git a/lib/film-scene/run.test.ts b/lib/film-scene/run.test.ts new file mode 100644 index 0000000..d1f1963 --- /dev/null +++ b/lib/film-scene/run.test.ts @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SceneRenderer } from "./scene"; + +const { createScene } = vi.hoisted(() => ({ + createScene: vi.fn<() => SceneRenderer | null>(), +})); +vi.mock("./scene", () => ({ createScene })); + +import { type RunOptions, runScene } from "./run"; + +const rgb: [number, number, number] = [0, 0, 0]; + +const palette = new Proxy({}, { get: () => rgb }) as RunOptions["palette"]; + +function options(): RunOptions { + return { + width: 100, + height: 100, + budget: { strips: 1, segments: 1, bokeh: 1, pixelRatio: 1 }, + palette, + // A still frame keeps the loop from scheduling anything. + reducedMotion: true, + visible: true, + onReady: vi.fn(), + onLost: vi.fn(), + }; +} + +function renderer(): SceneRenderer { + return { draw: vi.fn(), resize: vi.fn(), dispose: vi.fn() }; +} + +/** Only the event target half of a canvas is used once the renderer exists. */ +const canvas = () => new EventTarget() as unknown as OffscreenCanvas; + +/** covers: prompts/film-strip-background.md, AC-8 */ +describe("runScene", () => { + beforeEach(() => { + createScene.mockReset(); + }); + + it("draws nothing without WebGL2", () => { + createScene.mockReturnValue(null); + expect(runScene(canvas(), options())).toBeNull(); + }); + + it("draws nothing when a shader fails to link", () => { + createScene.mockImplementation(() => { + throw new Error("film-scene: shader program failed to link"); + }); + expect(runScene(canvas(), options())).toBeNull(); + }); + + it("reports a lost context and stops drawing", () => { + const scene = renderer(); + createScene.mockReturnValue(scene); + const target = canvas(); + const opts = options(); + runScene(target, opts); + (target as unknown as EventTarget).dispatchEvent( + new Event("webglcontextlost"), + ); + expect(opts.onLost).toHaveBeenCalledTimes(1); + expect(scene.dispose).toHaveBeenCalledTimes(1); + }); + + it("does not report its own release of the context as a loss", () => { + createScene.mockReturnValue(renderer()); + const target = canvas(); + const opts = options(); + runScene(target, opts)?.dispose(); + (target as unknown as EventTarget).dispatchEvent( + new Event("webglcontextlost"), + ); + expect(opts.onLost).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/film-scene/run.ts b/lib/film-scene/run.ts new file mode 100644 index 0000000..0bf31f4 --- /dev/null +++ b/lib/film-scene/run.ts @@ -0,0 +1,91 @@ +import type { SceneBudget } from "./budget"; +import { createClock, type FrameScheduler, type SceneClock } from "./clock"; +import type { ScenePalette } from "./palette"; +import { createScene, type SceneRenderer } from "./scene"; + +/** + * Everything one scene needs to start, the same fields whichever thread draws + * it, so the worker's `init` message spreads straight into it. + */ +export interface RunOptions { + width: number; + height: number; + budget: SceneBudget; + palette: ScenePalette; + reducedMotion: boolean; + visible: boolean; + onReady: () => void; + onLost: () => void; +} + +/** + * `requestAnimationFrame` where the global scope has it (every window, and + * dedicated workers in current browsers), a 60 per second timer otherwise. + */ +function frameScheduler(): FrameScheduler { + if (typeof requestAnimationFrame === "function") { + return { + request: (callback) => requestAnimationFrame(callback), + cancel: (handle) => cancelAnimationFrame(handle), + }; + } + return { + request: (callback) => + setTimeout( + () => callback(performance.now()), + 1000 / 60, + ) as unknown as number, + cancel: (handle) => clearTimeout(handle), + }; +} + +/** + * Scene plus clock on one canvas, the piece the worker and the main thread + * fallback share (prompts/film-strip-background.md, decision 7). + * + * Returns null when nothing can be drawn: no WebGL2, or a shader that does not + * link. A context lost later (a GPU reset, or the browser reclaiming memory + * from a background tab) stops the clock and calls `onLost`, a separate signal + * because the page then keeps its CSS gradient rather than rebuilding the + * scene elsewhere (decision 10). + */ +export function runScene( + canvas: HTMLCanvasElement | OffscreenCanvas, + options: RunOptions, +): SceneClock | null { + let renderer: SceneRenderer | null; + try { + renderer = createScene(canvas, options.budget, options.palette); + } catch { + renderer = null; + } + if (!renderer) return null; + + const clock = createClock({ + renderer, + scheduler: frameScheduler(), + reducedMotion: options.reducedMotion, + visible: options.visible, + onFirstFrame: options.onReady, + }); + + // Both canvas types dispatch the event; the cast only papers over their + // differing event maps. + const target = canvas as HTMLCanvasElement; + const onContextLost = () => { + clock.dispose(); + options.onLost(); + }; + target.addEventListener("webglcontextlost", onContextLost, { once: true }); + + clock.resize(options.width, options.height, options.budget.pixelRatio); + return { + ...clock, + // Disposing releases the context on purpose, which fires the same event; + // the listener goes first so that is never reported as a loss. + dispose() { + target.removeEventListener("webglcontextlost", onContextLost); + clock.dispose(); + }, + }; +} diff --git a/lib/film-scene/scene.test.ts b/lib/film-scene/scene.test.ts new file mode 100644 index 0000000..c214dd6 --- /dev/null +++ b/lib/film-scene/scene.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; + +import { FULL_BUDGET } from "./budget"; +import type { ScenePalette } from "./palette"; +import { createScene } from "./scene"; + +const palette = new Proxy({}, { get: () => [0.1, 0.2, 0.3] }) as ScenePalette; +const budget = { ...FULL_BUDGET, pixelRatio: 1 }; + +/** + * A WebGL2 context that records calls and does nothing, enough to build and + * draw the scene without a GPU. `linked` decides whether programs link. + */ +function fakeGl({ linked = true } = {}) { + const calls: Record = {}; + const log: string[] = []; + let handles = 0; + const gl = new Proxy({} as Record, { + get(target, name: string) { + if (name in target) return target[name]; + if (/^[A-Z_0-9]+$/.test(name)) return name; + return (...args: unknown[]) => { + calls[name] = (calls[name] ?? 0) + 1; + if (name === "depthMask") log.push(`depthMask:${args[0]}`); + if (name === "clear") log.push("clear"); + if (name === "getProgramParameter") return linked; + if (name === "getAttribLocation") return 0; + if (name === "getExtension") return { loseContext: vi.fn() }; + if (name.startsWith("create") || name === "getUniformLocation") { + handles += 1; + return { handle: handles, args }; + } + return undefined; + }; + }, + }); + const canvas = { + width: 0, + height: 0, + getContext: vi.fn(() => gl), + } as unknown as OffscreenCanvas; + return { canvas, calls, log }; +} + +/** covers: prompts/film-strip-background.md, AC-8, AC-13 */ +describe("createScene", () => { + it("draws nothing without WebGL2", () => { + const canvas = { getContext: () => null } as unknown as OffscreenCanvas; + expect(createScene(canvas, budget, palette)).toBeNull(); + }); + + it("throws when a program does not link, which the caller treats as no scene", () => { + const { canvas } = fakeGl({ linked: false }); + expect(() => createScene(canvas, budget, palette)).toThrow(/link/); + }); + + it("draws one call per strip and one for all the bokeh", () => { + const { canvas, calls } = fakeGl(); + const scene = createScene(canvas, budget, palette); + scene?.resize(1440, 900, 1.5); + scene?.draw({ time: 1, pointerX: 0.2, pointerY: -0.1 }); + expect(calls.drawElements).toBe(FULL_BUDGET.strips); + expect(calls.drawArraysInstanced).toBe(1); + expect(canvas.width).toBe(2160); + expect(canvas.height).toBe(1350); + }); + + it("turns depth writes back on before clearing each frame", () => { + const { canvas, log } = fakeGl(); + const scene = createScene(canvas, budget, palette); + scene?.resize(100, 100, 1); + scene?.draw({ time: 0, pointerX: 0, pointerY: 0 }); + log.length = 0; + scene?.draw({ time: 0, pointerX: 0, pointerY: 0 }); + expect(log.slice(0, 2)).toEqual(["depthMask:true", "clear"]); + }); + + it("deletes every buffer, array and program it made on dispose", () => { + const { canvas, calls } = fakeGl(); + createScene(canvas, budget, palette)?.dispose(); + expect(calls.deleteBuffer).toBe(calls.createBuffer); + expect(calls.deleteVertexArray).toBe(calls.createVertexArray); + expect(calls.deleteProgram).toBe(2); + }); +}); diff --git a/lib/film-scene/scene.ts b/lib/film-scene/scene.ts new file mode 100644 index 0000000..8b3ed06 --- /dev/null +++ b/lib/film-scene/scene.ts @@ -0,0 +1,327 @@ +import type { SceneBudget } from "./budget"; +import { + BOKEH_STRIDE, + bokehInstances, + CAMERA, + placeStrip, + RIBBON_STRIDE, + ribbon, + STRIPS, +} from "./layout"; +import { lookAt, perspective, stripModel } from "./matrices"; +import { peakLuminance, type ScenePalette } from "./palette"; +import { + BOKEH_FRAGMENT, + BOKEH_VERTEX, + STRIP_FRAGMENT, + STRIP_VERTEX, +} from "./shaders"; + +/** What changes from frame to frame: the clock and the smoothed pointer. */ +export interface FrameState { + /** Scene seconds: real seconds times the design's speed. */ + time: number; + /** The pointer from -0.5 to 0.5 across and down the window. */ + pointerX: number; + pointerY: number; +} + +/** + * The scene without a clock: it draws whatever frame it is handed, so the + * clock decides when, and tests can drive it with no GPU. + */ +export interface SceneRenderer { + draw(frame: FrameState): void; + resize(width: number, height: number, pixelRatio: number): void; + dispose(): void; +} + +type Canvas = HTMLCanvasElement | OffscreenCanvas; + +const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT; + +/** + * Transparent, so the CSS gradient behind shows through and the bokeh add + * onto it as the design's additive sprites did; a depth buffer, so the + * strips hide the discs behind them; and the low power GPU where there is a + * choice. + */ +const CONTEXT_OPTIONS: WebGLContextAttributes = { + alpha: true, + premultipliedAlpha: true, + antialias: true, + depth: true, + stencil: false, + powerPreference: "low-power", + preserveDrawingBuffer: false, +}; + +function compile( + gl: WebGL2RenderingContext, + vertex: string, + fragment: string, +): WebGLProgram { + const program = gl.createProgram(); + const shaders = [ + [gl.VERTEX_SHADER, vertex], + [gl.FRAGMENT_SHADER, fragment], + ] as const; + for (const [type, source] of shaders) { + const shader = gl.createShader(type); + if (!shader) throw new Error("film-scene: shader allocation failed"); + gl.shaderSource(shader, source); + gl.compileShader(shader); + gl.attachShader(program, shader); + gl.deleteShader(shader); + } + gl.linkProgram(program); + // Checked once after linking, never per shader, so a healthy page does not + // stall on the compile status queries. + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error("film-scene: shader program failed to link"); + } + return program; +} + +/** + * `gl.useProgram` under another name. Biome's hook rule reads any `use*` call + * as a React hook, so the one real call sits here behind one suppression. + */ +function activate(gl: WebGL2RenderingContext, program: WebGLProgram) { + // biome-ignore lint/correctness/useHookAtTopLevel: a WebGL call, not a hook + gl.useProgram(program); +} + +function upload( + gl: WebGL2RenderingContext, + target: GLenum, + data: Float32Array | Uint16Array, +): WebGLBuffer { + const handle = gl.createBuffer(); + gl.bindBuffer(target, handle); + gl.bufferData(target, data, gl.STATIC_DRAW); + return handle; +} + +function attribute( + gl: WebGL2RenderingContext, + program: WebGLProgram, + name: string, + size: number, + stride: number, + offset: number, + divisor = 0, +) { + const location = gl.getAttribLocation(program, name); + if (location < 0) return; + gl.enableVertexAttribArray(location); + gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, offset); + gl.vertexAttribDivisor(location, divisor); +} + +function uniforms( + gl: WebGL2RenderingContext, + program: WebGLProgram, + names: readonly Name[], +): Record { + return Object.fromEntries( + names.map((name) => [name, gl.getUniformLocation(program, name)]), + ) as Record; +} + +/** `value` wrapped into 0 to 1, so the film scroll never loses precision. */ +const wrap = (value: number) => value - Math.floor(value); + +/** + * The film strip scene, drawn with plain WebGL2 rather than Three.js + * (prompts/film-strip-background.md, decision 1). One draw call per strip + * and one for every bokeh disc at once. + * + * It touches no DOM, so the same module runs in a worker on an + * `OffscreenCanvas` and on the main thread on a canvas element. Returns null + * when the canvas has no WebGL2, which leaves the page on its CSS gradient; a + * shader that fails to link throws, and the caller treats that the same way. + */ +export function createScene( + canvas: Canvas, + budget: SceneBudget, + palette: ScenePalette, +): SceneRenderer | null { + const gl = canvas.getContext( + "webgl2", + CONTEXT_OPTIONS, + ) as WebGL2RenderingContext | null; + if (!gl) return null; + + const film = compile(gl, STRIP_VERTEX, STRIP_FRAGMENT); + const bokeh = compile(gl, BOKEH_VERTEX, BOKEH_FRAGMENT); + const filmU = uniforms(gl, film, [ + "uModel", + "uView", + "uProj", + "uScroll", + "uEye", + "uPeak", + "uBase", + "uWindow", + "uShadow", + "uEdge", + "uRail", + "uEmissive", + "uAmbient", + "uKey", + "uRim", + "uFog", + ]); + const bokehU = uniforms(gl, bokeh, [ + "uView", + "uProj", + "uTime", + "uPeak", + "uGlow", + "uTints", + ]); + + const buffers: WebGLBuffer[] = []; + const arrays: WebGLVertexArrayObject[] = []; + const vao = (setup: () => void) => { + const handle = gl.createVertexArray(); + gl.bindVertexArray(handle); + setup(); + gl.bindVertexArray(null); + arrays.push(handle); + return handle; + }; + + const strips = STRIPS.slice(0, budget.strips).map((layout) => { + const { vertices, indices } = ribbon(layout, budget.segments); + const handle = vao(() => { + buffers.push(upload(gl, gl.ARRAY_BUFFER, vertices)); + const stride = RIBBON_STRIDE * FLOAT_BYTES; + attribute(gl, film, "aPosition", 3, stride, 0); + attribute(gl, film, "aNormal", 3, stride, 3 * FLOAT_BYTES); + attribute(gl, film, "aUv", 2, stride, 6 * FLOAT_BYTES); + buffers.push(upload(gl, gl.ELEMENT_ARRAY_BUFFER, indices)); + }); + return { layout, vao: handle, count: indices.length, x: 0, y: 0 }; + }); + + const bokehCount = budget.bokeh; + const bokehVao = vao(() => { + // biome-ignore format: corner pairs + buffers.push(upload(gl, gl.ARRAY_BUFFER, new Float32Array([ + -0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5, + ]))); + attribute(gl, bokeh, "aCorner", 2, 0, 0); + buffers.push(upload(gl, gl.ARRAY_BUFFER, bokehInstances(bokehCount))); + const stride = BOKEH_STRIDE * FLOAT_BYTES; + attribute(gl, bokeh, "aStart", 4, stride, 0, 1); + attribute(gl, bokeh, "aMotion", 4, stride, 4 * FLOAT_BYTES, 1); + attribute(gl, bokeh, "aTint", 1, stride, 8 * FLOAT_BYTES, 1); + }); + + // Colours never change while the page is open, so they are set once. + const peak = peakLuminance(palette.text); + activate(gl, film); + gl.uniform1f(filmU.uPeak, peak); + for (const [name, color] of [ + [filmU.uBase, palette.filmBase], + [filmU.uWindow, palette.filmWindow], + [filmU.uShadow, palette.filmShadow], + [filmU.uEdge, palette.filmEdge], + [filmU.uRail, palette.filmRail], + [filmU.uEmissive, palette.filmGlow], + [filmU.uAmbient, palette.lightAmbient], + [filmU.uKey, palette.lightKey], + [filmU.uRim, palette.lightRim], + [filmU.uFog, palette.fog], + ] as const) { + gl.uniform3fv(name, color); + } + activate(gl, bokeh); + gl.uniform1f(bokehU.uPeak, peak); + gl.uniform3fv(bokehU.uGlow, palette.glow); + gl.uniform3fv( + bokehU.uTints, + [palette.bokeh1, palette.bokeh2, palette.bokeh3, palette.bokeh4].flat(), + ); + + gl.clearColor(0, 0, 0, 0); + gl.enable(gl.DEPTH_TEST); + // MAX keeps the brightest disc rather than summing them, so the cap each + // fragment applies holds where they overlap (decision 5). + gl.blendEquation(gl.MAX); + + let projection = perspective(CAMERA.fovY, 1, CAMERA.near, CAMERA.far); + + return { + resize(width, height, ratio) { + canvas.width = Math.max(1, Math.round(width * ratio)); + canvas.height = Math.max(1, Math.round(height * ratio)); + gl.viewport(0, 0, canvas.width, canvas.height); + const aspect = width / Math.max(1, height); + projection = perspective(CAMERA.fovY, aspect, CAMERA.near, CAMERA.far); + for (const strip of strips) { + Object.assign(strip, placeStrip(strip.layout, aspect)); + } + }, + + draw({ time, pointerX, pointerY }) { + const eye = [ + pointerX * CAMERA.parallaxX, + -pointerY * CAMERA.parallaxY, + CAMERA.z, + ] as const; + const view = lookAt(eye, CAMERA.target); + // The depth mask goes back on before the clear: the bokeh pass turned + // it off, and a clear with it off leaves last frame's depth behind, + // which would hide this frame's strips wherever they did not move. + gl.depthMask(true); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + + // The strips are opaque: depth written, nothing blended. + gl.disable(gl.BLEND); + activate(gl, film); + gl.uniformMatrix4fv(filmU.uView, false, view); + gl.uniformMatrix4fv(filmU.uProj, false, projection); + gl.uniform3fv(filmU.uEye, eye); + strips.forEach((strip, i) => { + const { layout } = strip; + const model = stripModel({ + x: strip.x, + y: strip.y + Math.sin(time * 0.4 + i * 2) * 0.15, + z: layout.z, + rx: layout.rx, + rz: layout.rz + Math.sin(time * 0.25 + i) * 0.05, + scale: layout.scale, + spin: layout.spin * time, + }); + gl.uniformMatrix4fv(filmU.uModel, false, model); + gl.uniform1f(filmU.uScroll, wrap(-layout.run * time)); + gl.bindVertexArray(strip.vao); + gl.drawElements(gl.TRIANGLES, strip.count, gl.UNSIGNED_SHORT, 0); + }); + + // The bokeh: hidden behind strips, but never hiding each other. + gl.enable(gl.BLEND); + gl.depthMask(false); + activate(gl, bokeh); + gl.uniformMatrix4fv(bokehU.uView, false, view); + gl.uniformMatrix4fv(bokehU.uProj, false, projection); + gl.uniform1f(bokehU.uTime, time); + gl.bindVertexArray(bokehVao); + gl.drawArraysInstanced(gl.TRIANGLE_STRIP, 0, 4, bokehCount); + gl.bindVertexArray(null); + }, + + dispose() { + for (const handle of arrays) gl.deleteVertexArray(handle); + for (const handle of buffers) gl.deleteBuffer(handle); + gl.deleteProgram(film); + gl.deleteProgram(bokeh); + // Hands the GPU memory back now instead of whenever the canvas is + // collected, which matters on a phone navigating away. + gl.getExtension("WEBGL_lose_context")?.loseContext(); + }, + }; +} diff --git a/lib/film-scene/shaders.ts b/lib/film-scene/shaders.ts new file mode 100644 index 0000000..16c6635 --- /dev/null +++ b/lib/film-scene/shaders.ts @@ -0,0 +1,219 @@ +/** + * GLSL ES 3.00 for the two programs of the background + * (prompts/film-strip-background.md, decisions 1 and 5). + * + * The constants are the design file's, kept as they were so the look matches + * it: the film frame was a 360 × 256 canvas texture, the material Three's + * `MeshStandardMaterial` (roughness and metalness 0.35, emissive at 0.28), + * lit by an ambient, a directional and a point light, under linear fog from + * 8 to 22. Three r149 lit in the raw sRGB values and wrote the result + * unconverted, and so does this. + * + * Both fragment shaders cap what they write to `uPeak`, the brightest + * luminance at which muted text keeps 4.5:1. The strips are opaque, so their + * cap is exact. The bokeh add their light onto the CSS gradient behind the + * canvas, so their cap counts the gradient's brightest colour (`uGlow`) as + * already there, and they blend with `MAX` so overlapping discs never add up. + */ + +const CAP = /* glsl */ ` +uniform float uPeak; +float luma(vec3 c) { + return dot(pow(max(c, 0.0), vec3(2.2)), vec3(0.2126, 0.7152, 0.0722)); +} +vec3 capped(vec3 c) { + float l = luma(c); + return l > uPeak ? c * pow(uPeak / l, 1.0 / 2.2) : c; +} +`; + +/** The film ribbon, already in place: the CPU sends one matrix per strip. */ +export const STRIP_VERTEX = /* glsl */ `#version 300 es +in vec3 aPosition; +in vec3 aNormal; +in vec2 aUv; +uniform mat4 uModel; +uniform mat4 uView; +uniform mat4 uProj; +uniform float uScroll; +out vec3 vWorld; +out vec3 vNormal; +out vec2 vUv; +out float vDepth; +void main() { + vec4 world = uModel * vec4(aPosition, 1.0); + vec4 view = uView * world; + vWorld = world.xyz; + vNormal = mat3(uModel) * aNormal; + vUv = vec2(aUv.x + uScroll, aUv.y); + vDepth = -view.z; + gl_Position = uProj * view; +} +`; + +/** + * The film frame, drawn where the design drew its canvas texture, in the + * texture's own pixels: a teal base, a dark window with a vertical gradient + * and a bright border, rails along both edges, and four rounded sprocket + * holes top and bottom, which are discarded as the design's `alphaTest` cut + * them. Edges are smoothed over one screen pixel with `fwidth`. + */ +export const STRIP_FRAGMENT = /* glsl */ `#version 300 es +precision highp float; +uniform vec3 uBase; +uniform vec3 uWindow; +uniform vec3 uShadow; +uniform vec3 uEdge; +uniform vec3 uRail; +uniform vec3 uEmissive; +uniform vec3 uAmbient; +uniform vec3 uKey; +uniform vec3 uRim; +uniform vec3 uFog; +uniform vec3 uEye; +${CAP} +in vec3 vWorld; +in vec3 vNormal; +in vec2 vUv; +in float vDepth; +out vec4 outColor; + +const float PI = 3.14159265; +const vec3 KEY_DIR = vec3(0.37139068, 0.74278135, 0.55708601); +const vec3 RIM_POS = vec3(-3.0, 2.0, 5.0); +const float ROUGHNESS = 0.35; +const float METALNESS = 0.35; + +float box(vec2 p, vec2 extent, float r) { + vec2 q = abs(p) - extent + r; + return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r; +} + +float inside(float d, float w) { + return 1.0 - smoothstep(-w, w, d); +} + +vec3 directLight(vec3 n, vec3 v, vec3 l, vec3 radiance, vec3 diffuse, vec3 f0) { + float nl = clamp(dot(n, l), 0.0, 1.0); + if (nl <= 0.0) return vec3(0.0); + vec3 h = normalize(l + v); + float nv = clamp(abs(dot(n, v)), 1e-4, 1.0); + float nh = clamp(dot(n, h), 0.0, 1.0); + float vh = clamp(dot(v, h), 0.0, 1.0); + float a = ROUGHNESS * ROUGHNESS; + float a2 = a * a; + vec3 f = f0 + (1.0 - f0) * pow(1.0 - vh, 5.0); + float gv = nl * sqrt(a2 + (1.0 - a2) * nv * nv); + float gl = nv * sqrt(a2 + (1.0 - a2) * nl * nl); + float vis = 0.5 / max(gv + gl, 1e-6); + float dd = nh * nh * (a2 - 1.0) + 1.0; + float d = a2 / (PI * dd * dd); + return nl * radiance * (diffuse + PI * f * vis * d); +} + +void main() { + vec2 px = vec2(vUv.x * 360.0, (1.0 - vUv.y) * 256.0); + float w = max(fwidth(px.x), fwidth(px.y)) * 0.5 + 1e-3; + float x = mod(px.x, 360.0); + float y = px.y; + + // The sprocket holes, every 90 pixels along, 62 × 24 with 5 pixel corners. + float column = floor(x / 90.0); + vec2 hole = vec2(x - (45.0 + column * 90.0), y - (y < 128.0 ? 24.0 : 232.0)); + if (box(hole, vec2(31.0, 12.0), 5.0) < 0.0) discard; + + vec3 albedo = uBase; + vec2 frame = vec2(x - 180.0, y - 128.0); + float t = clamp((y - 52.0) / 152.0, 0.0, 1.0); + vec3 glass = mix(uShadow, uWindow, 1.0 - abs(2.0 * t - 1.0)); + float d = box(frame, vec2(164.0, 76.0), 0.0); + albedo = mix(albedo, glass, inside(d, w)); + albedo = mix(albedo, uEdge, inside(abs(d) - 1.5, w)); + float rail = min(y, 256.0 - y) - 3.0; + albedo = mix(albedo, uRail, inside(rail, w)); + + vec3 n = normalize(vNormal) * (gl_FrontFacing ? 1.0 : -1.0); + vec3 v = normalize(uEye - vWorld); + vec3 diffuse = albedo * (1.0 - METALNESS); + vec3 f0 = mix(vec3(0.04), albedo, METALNESS); + + vec3 color = uAmbient * 0.45 * diffuse; + color += directLight(n, v, KEY_DIR, uKey * 0.9, diffuse, f0); + vec3 toRim = RIM_POS - vWorld; + float falloff = pow(clamp(1.0 - length(toRim) / 30.0, 0.0, 1.0), 2.0); + color += directLight(n, v, normalize(toRim), uRim * 1.4 * falloff, diffuse, f0); + color += uEmissive * 0.28 * albedo; + + color = mix(color, uFog, smoothstep(8.0, 22.0, vDepth)); + outColor = vec4(capped(color), 1.0); +} +`; + +/** + * The bokeh, one instanced quad each, facing the camera. Each disc's place + * is a function of the clock: it rises and drifts, and when it passes the + * top it comes back in at the bottom at a new place across, as the design + * moved it on the CPU. Its opacity breathes on its own phase. + */ +export const BOKEH_VERTEX = /* glsl */ `#version 300 es +in vec2 aCorner; +in vec4 aStart; +in vec4 aMotion; +in float aTint; +uniform mat4 uView; +uniform mat4 uProj; +uniform float uTime; +uniform vec3 uTints[4]; +out vec2 vCorner; +out vec3 vLight; + +float hash(vec2 p) { + return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() { + float rise = aMotion.x; + float cycle = (aStart.y + 8.0 + rise * uTime) / 16.0; + float lap = floor(cycle); + float y = fract(cycle) * 16.0 - 8.0; + float since = lap < 1.0 ? uTime : uTime - (lap * 16.0 - aStart.y - 8.0) / rise; + float x0 = lap < 1.0 + ? aStart.x + : (hash(vec2(float(gl_InstanceID), lap)) - 0.5) * 22.0; + vec4 view = uView * vec4(x0 + aMotion.y * since, y, aStart.z, 1.0); + view.xy += aCorner * aStart.w; + gl_Position = uProj * view; + vCorner = aCorner; + float opacity = aMotion.z * 0.6 * (0.7 + 0.3 * sin(uTime * 0.9 + aMotion.w)); + vLight = uTints[int(aTint)] * opacity; +} +`; + +/** + * The disc itself, the design's radial gradient: a soft body, a brighter + * ring near the rim, and a quick fade at the edge. + */ +export const BOKEH_FRAGMENT = /* glsl */ `#version 300 es +precision mediump float; +uniform vec3 uGlow; +${CAP} +in vec2 vCorner; +in vec3 vLight; +out vec4 outColor; + +float profile(float r) { + if (r >= 1.0) return 0.0; + if (r < 0.72) return mix(0.55, 0.42, r / 0.72); + if (r < 0.88) return mix(0.42, 0.6, (r - 0.72) / 0.16); + if (r < 0.97) return mix(0.6, 0.08, (r - 0.88) / 0.09); + return mix(0.08, 0.0, (r - 0.97) / 0.03); +} + +void main() { + vec3 light = vLight * profile(length(vCorner) * 2.0); + float under = luma(uGlow); + float lit = luma(uGlow + light); + if (lit > uPeak) light *= max(0.0, (uPeak - under) / (lit - under)); + outColor = vec4(light, 0.0); +} +`; diff --git a/lib/film-scene/start.ts b/lib/film-scene/start.ts new file mode 100644 index 0000000..514efc2 --- /dev/null +++ b/lib/film-scene/start.ts @@ -0,0 +1,255 @@ +import { chooseBudget, sameGeometry } from "./budget"; +import type { FromWorker, ToWorker } from "./messages"; +import { readScenePalette } from "./palette"; + +/** The page side of a running scene, whichever thread draws it. */ +interface Controller { + resize(width: number, height: number): void; + pointer(x: number, y: number): void; + visible(visible: boolean): void; + motion(reduced: boolean): void; + dispose(): void; +} + +/** The design's fade in. */ +const FADE = "opacity 1.6s ease"; + +function supportsWorkerCanvas(): boolean { + return ( + typeof Worker === "function" && + typeof HTMLCanvasElement.prototype.transferControlToOffscreen === "function" + ); +} + +const clampHalf = (value: number) => Math.max(-0.5, Math.min(0.5, value)); + +/** + * Starts the film strip scene inside `host` and returns its cleanup + * (prompts/film-strip-background.md, decisions 6 to 10). + * + * The canvas is created here rather than rendered by React: once its control + * is transferred to a worker it can never be transferred again, so a canvas + * React kept across a Strict Mode remount would throw. Each start gets a + * fresh one and the cleanup removes it. + * + * Draws in a worker where the browser can hand a canvas to one, and on the + * main thread otherwise, or when the worker cannot draw (Safari 16 has + * `OffscreenCanvas` but no WebGL on it). The scene chunk is loaded only on the + * path that needs it. If neither can draw, the canvas is removed and the page + * keeps its CSS gradient and vignette. + * + * The budget follows the viewport (decision 8): a resize across the compact + * breakpoint rebuilds the scene at the other density, because its geometry is + * uploaded once. A resize within one budget only resizes the canvas, which + * also places the strips again for the new shape. + * + * The canvas stays transparent until the first frame is drawn, then fades in, + * except under reduced motion, where it appears without a transition. + * + * `still` draws the reduced motion frame whatever the visitor's setting, so + * `/showcase` can show it beside the live background. + */ +export function startFilmScene( + host: HTMLElement, + { still = false }: { still?: boolean } = {}, +): () => void { + const palette = readScenePalette(host); + if (!palette) return () => {}; + + const motionQuery = matchMedia("(prefers-reduced-motion: reduce)"); + const reducedMotion = () => still || motionQuery.matches; + const coarseQuery = matchMedia("(pointer: coarse)"); + const currentBudget = () => + chooseBudget({ + viewportWidth: innerWidth, + coarsePointer: coarseQuery.matches, + devicePixelRatio, + }); + + let budget = currentBudget(); + let controller: Controller | null = null; + let canvas: HTMLCanvasElement | null = null; + let disposed = false; + let stopped = false; + let workerUsable = supportsWorkerCanvas(); + // Bumped by every start, so a slow main thread start or a late callback + // from a scene already replaced cannot touch the current one. + let run = 0; + + const size = () => { + const rect = host.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }; + const state = () => ({ + budget, + palette, + reducedMotion: reducedMotion(), + visible: !document.hidden, + }); + + const freshCanvas = () => { + canvas?.remove(); + const next = document.createElement("canvas"); + next.style.cssText = + "position:absolute;inset:0;width:100%;height:100%;display:block;opacity:0"; + host.prepend(next); + canvas = next; + return next; + }; + + const reveal = () => { + if (!canvas) return; + canvas.style.transition = reducedMotion() ? "none" : FADE; + canvas.style.opacity = "1"; + }; + + const teardown = () => { + controller?.dispose(); + controller = null; + canvas?.remove(); + canvas = null; + }; + + // The worker could not draw at all: the main thread may still manage. + const fallBack = () => { + teardown(); + workerUsable = false; + if (!disposed) start(); + }; + + // A context lost after drawing is not retried anywhere (decision 7): a GPU + // reset or memory pressure would only take the next one away too. + const giveUp = () => { + teardown(); + stopped = true; + }; + + /** Wraps a callback so it is ignored once its scene has been replaced. */ + const current = (own: number, callback: () => void) => () => { + if (own === run) callback(); + }; + + async function startMainThread(own: number) { + const target = freshCanvas(); + let runScene: typeof import("./run").runScene; + try { + ({ runScene } = await import("./run")); + } catch { + return current(own, giveUp)(); + } + if (disposed || own !== run) return; + const clock = runScene(target, { + ...size(), + ...state(), + onReady: current(own, reveal), + onLost: current(own, giveUp), + }); + if (!clock) return giveUp(); + controller = { + resize: (width, height) => clock.resize(width, height, budget.pixelRatio), + pointer: clock.setPointer, + visible: clock.setVisible, + motion: clock.setReducedMotion, + dispose: clock.dispose, + }; + } + + function startWorker(own: number) { + const offscreen = freshCanvas().transferControlToOffscreen(); + const worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + name: "film-scene", + }); + const post = (message: ToWorker, transfer: Transferable[] = []) => + worker.postMessage(message, transfer); + worker.onmessage = (event: MessageEvent) => { + if (own !== run) return; + if (event.data.type === "ready") reveal(); + else if (event.data.type === "lost") giveUp(); + else fallBack(); + }; + worker.onerror = current(own, fallBack); + post({ type: "init", canvas: offscreen, ...size(), ...state() }, [ + offscreen, + ]); + controller = { + resize: (width, height) => + post({ + type: "resize", + width, + height, + pixelRatio: budget.pixelRatio, + }), + pointer: (x, y) => post({ type: "pointer", x, y }), + visible: (visible) => post({ type: "visible", visible }), + motion: (reduced) => post({ type: "motion", reduced }), + dispose: () => worker.terminate(), + }; + } + + function start() { + run += 1; + if (!workerUsable) { + void startMainThread(run); + return; + } + try { + startWorker(run); + } catch { + fallBack(); + } + } + + // The host is sized to the large viewport (`h-lvh`), so a phone's toolbar + // collapsing on scroll does not resize, and so reallocate, the canvas. + const resizeObserver = new ResizeObserver(() => { + if (stopped) return; + const next = currentBudget(); + const rebuild = !sameGeometry(budget, next); + budget = next; + if (rebuild) { + teardown(); + start(); + return; + } + const { width, height } = size(); + controller?.resize(width, height); + }); + + // One message per frame at most: a pointer fires far more often than the + // scene redraws, and each message would wake the worker for nothing. + let pointerFrame: number | null = null; + let pointerX = 0; + let pointerY = 0; + const onPointerMove = (event: PointerEvent) => { + // The still frame ignores the pointer (AC-6), so nothing is sent. + if (reducedMotion()) return; + pointerX = clampHalf(event.clientX / innerWidth - 0.5); + pointerY = clampHalf(event.clientY / innerHeight - 0.5); + if (pointerFrame !== null) return; + pointerFrame = requestAnimationFrame(() => { + pointerFrame = null; + controller?.pointer(pointerX, pointerY); + }); + }; + const onVisibility = () => controller?.visible(!document.hidden); + const onMotion = () => controller?.motion(reducedMotion()); + + start(); + + resizeObserver.observe(host); + addEventListener("pointermove", onPointerMove, { passive: true }); + document.addEventListener("visibilitychange", onVisibility); + motionQuery.addEventListener("change", onMotion); + + return () => { + disposed = true; + run += 1; + resizeObserver.disconnect(); + removeEventListener("pointermove", onPointerMove); + document.removeEventListener("visibilitychange", onVisibility); + motionQuery.removeEventListener("change", onMotion); + if (pointerFrame !== null) cancelAnimationFrame(pointerFrame); + teardown(); + }; +} diff --git a/lib/film-scene/worker.ts b/lib/film-scene/worker.ts new file mode 100644 index 0000000..2c7149e --- /dev/null +++ b/lib/film-scene/worker.ts @@ -0,0 +1,57 @@ +import type { SceneClock } from "./clock"; +import { type FromWorker, toWorkerSchema } from "./messages"; +import { runScene } from "./run"; + +/** + * The background's worker entry (prompts/film-strip-background.md, + * decision 7). + * + * The canvas arrives as an `OffscreenCanvas` in the `init` message and every + * frame is drawn here, so React hydration, scrolling and input on the main + * thread never wait on the animation, nor it on them. A message that fails + * validation is dropped. There is no dispose message: the page terminates + * the worker, which releases the context and everything else at once. + */ + +/** The slice of `DedicatedWorkerGlobalScope` this file uses. */ +interface WorkerScope { + postMessage(message: FromWorker): void; + onmessage: ((event: MessageEvent) => void) | null; +} + +const scope = self as unknown as WorkerScope; +let clock: SceneClock | null = null; + +scope.onmessage = (event) => { + const parsed = toWorkerSchema.safeParse(event.data); + if (!parsed.success) return; + const message = parsed.data; + + switch (message.type) { + case "init": { + if (clock) return; + clock = runScene(message.canvas, { + ...message, + onReady: () => scope.postMessage({ type: "ready" }), + onLost: () => { + clock = null; + scope.postMessage({ type: "lost" }); + }, + }); + if (!clock) scope.postMessage({ type: "failed" }); + return; + } + case "resize": + clock?.resize(message.width, message.height, message.pixelRatio); + return; + case "pointer": + clock?.setPointer(message.x, message.y); + return; + case "visible": + clock?.setVisible(message.visible); + return; + case "motion": + clock?.setReducedMotion(message.reduced); + return; + } +}; diff --git a/prompts/film-strip-background.md b/prompts/film-strip-background.md new file mode 100644 index 0000000..0198421 --- /dev/null +++ b/prompts/film-strip-background.md @@ -0,0 +1,164 @@ +# Film strip background on every page + +## Goal + +Replace the ambient background (prompts/ambient-background.md: point wave, drifting screens, dust) with the Claude Design file `BeStats Background v2.dc.html`, and remove every piece of the old one: code, tokens, tests and showcase entry. The new scene has these layers: + +- A teal radial gradient. +- Three lit film strips coiled into helices, each with a sprocket rail, dark frame windows and punched holes, scrolling along their length while they spin and bob. +- About 70 soft bokeh discs rising slowly. +- A vignette. +- A slight camera parallax that follows the pointer. + +It must cost the catalog pages nothing measurable in load time, interactivity or battery. + +## Inspected code and sources + +- **The design file** (read through Claude Design): a Three.js r149 scene loaded from unpkg. It has these parts: + - A `CanvasTexture` film frame, 360 × 256: base `#0e6a80`, a window gradient `#01090c → #05303a → #01090c`, a `#4fe0ff` window border, `#3fd8ff` rails, and 4 × 2 rounded sprocket holes cut out with `destination-out`. + - Ribbon geometry, 400 segments, swept along a `CatmullRomCurve3` helix of 61 points (radius bulging by `1 + 0.25·sin(πt)`). Its `u` coordinate repeats once every `1.4 × width`. + - `MeshStandardMaterial`: roughness 0.35, metalness 0.35, emissive `#1fb8d8` at 0.28 through the same map, double sided, `alphaTest: 0.5`. + - Lights: ambient `#145060` at 0.45, directional key `#a8f4ff` at 0.9 from (4, 8, 6), and point rim `#2fd8ff` at 1.4 from (-3, 2, 5) with a range of 30. `Fog(#010a10, 8, 22)`. + - A strip layout table of up to four entries. Each entry gives a frustum relative screen position (`fx`, `fy`), a depth, a tilt, a spin, and a texture run speed. + - 70 additive sprites in four tints (`#8ff0ff`, `#c9f8ff`, `#fff`, `#4fd8f0`). 30% of them are large and faint, the rest small and bright. They rise and wrap at y = ±8, and their opacity breathes. + - Props: `speed` 0.25, `strips` 3, `bokeh` 70. + - The background `div` is `radial-gradient(ellipse at 25% 15%, #04303a, #021820 45%, #010a10 80%, #000306)`. The vignette `div` is `radial-gradient(ellipse at 50% 45%, rgba(0,4,6,.15), rgba(0,4,6,.55) 60%, rgba(0,2,4,.95))`. + - Pointer parallax lerps 0.02 per frame. Under reduced motion, time stops. The canvas fades in over 1.6 s. +- **`support.js`**: the Claude Design canvas runtime. Nothing in it ships. +- **The old background, all removed:** + - `components/layout/ambient-background.tsx` and `ambient-canvas.tsx`, with their tests. + - `lib/backdrop/*`, 16 files. + - `--color-backdrop-wave`, `--color-backdrop-dust` and `@utility backdrop-vignette` in `app/globals.css`. + - The "Ambient background" section of `app/showcase/page.tsx`. + - Its entries in `app/layout.tsx`, `app/layout.test.ts`, `app/layout-purity.test.ts` and `components/AGENTS.md`. +- **`app/layout.tsx`**: the root shell, and the rule that nothing in it reads request state. **`app/globals.css`**: the only home of colour literals (`design-tokens-boundary.test.ts`). `--background` is `#000`, and `--muted-foreground` is `#a8adbc`. +- **Context7:** + - three.js manual: OffscreenCanvas with a main thread fallback, and resource disposal. + - Next.js docs: lazy loading with `import()` keeps a library out of the initial bundle, and Turbopack resolves `new Worker(new URL("./x.ts", import.meta.url))` into its own chunk. + - What this means here: hand-written WebGL2 in a worker, imported after hydration, with every buffer, program and texture deleted on dispose. + +## Skills used + +`next-dev-loop` to verify the running app. `next-cache-components-optimizer` guidance to confirm that `/shows`, `/movies` and `/movies/[id]` keep their static shells. No Supabase skill is needed: there is no data change. + +## Decisions (owner answers, 2026-10-11) and assumptions + +1. **Hand-written WebGL2, no three.js** (owner). No new dependency. Two programs and `strips + 1` draw calls a frame: + - **Strips.** One ribbon mesh per strip, built once on the CPU from the helix: Catmull-Rom sampling, then the design's side vector `up − tan·(up·tan)`. The spin, bob, sway and texture scroll are uniforms per strip. The film frame is computed procedurally in the fragment shader from `uv`: the rails, the window with its vertical gradient and border, and rounded rectangle sprocket holes that `discard`, which is the design's `alphaTest`. So there is no texture upload and no 2D canvas in the worker. The lighting approximates `MeshStandardMaterial` with Lambert diffuse plus GGX-lite specular from the key and the rim light, plus ambient and emissive. Linear fog runs from 8 to 22. + - **Bokeh.** One instanced quad draw. Each instance gets a seeded position, size, tint, base opacity, phase and velocity. The rise, the wrap and the breathing opacity are all computed in the vertex shader from `time`, so the CPU loop over 70 sprites is gone. The disc profile is the design's radial gradient stops, computed analytically. + - The CPU sends a handful of uniforms per frame and nothing else. +2. **Rebuilt fresh under new names** (owner). Everything of the old background is deleted. The delivery guarantees are rebuilt and retested: idle import, a worker with a main thread fallback, reduced motion, a hidden tab pause and silent failure. + - New names: `components/layout/page-background.tsx`, `components/layout/page-background-canvas.tsx` and `lib/film-scene/`. + - `prompts/ambient-background.md` stays as the historical record, with one line on top pointing here. +3. **The design's teal palette, as backdrop-only tokens** (owner). All new tokens go in `@theme` in `globals.css` and are read at mount with `getComputedStyle`. None is written in a shader. + - Gradient: `--color-backdrop-glow` `#04303a`, `--color-backdrop-mid` `#021820`, `--color-backdrop-deep` `#010a10` (also the fog), and `--color-backdrop-abyss` `#000306`. + - Film: `--color-film-base` `#0e6a80`, `--color-film-window` `#05303a`, `--color-film-shadow` `#01090c`, `--color-film-edge` `#4fe0ff`, `--color-film-rail` `#3fd8ff` and `--color-film-glow` `#1fb8d8`. + - Lights: `--color-light-ambient` `#145060`, `--color-light-key` `#a8f4ff` and `--color-light-rim` `#2fd8ff`. + - Bokeh: `--color-bokeh-1` to `--color-bokeh-4`, `#8ff0ff`, `#c9f8ff`, `#ffffff` and `#4fd8f0`. + - The gradient and the vignette are CSS utilities (`backdrop-gradient`, `backdrop-vignette`) on server rendered layers. They are therefore the first paint, and the whole background where WebGL2 is missing. + - The page's `--background` stays `#000`. The OG image, the theme colour metadata (`lib/seo/theme-tokens.ts`) and the plates are untouched. The teal shows only in the fixed background layer. +4. **The personal cyan exception ends.** The background no longer borrows `--color-score-personal`, so `components/AGENTS.md` drops that exception and keeps the selected tab as the only one. The design's cyans are distinct tokens. Every rating badge still sits on an opaque plate, so a score never reads as part of the scene. +5. **The contrast cap is kept** (owner). Every fragment of both programs is clamped to the peak luminance at which `text-muted-foreground` keeps 4.5:1. The value is derived from the token at mount, with the old 0.85 margin. Bokeh uses `MAX` blending instead of additive, so overlapping discs cannot add up past the cap. Over the dark teal this matches additive blending wherever discs do not overlap. The brightest parts of the design, the `#4fe0ff` window border and the white bokeh cores, render slightly dimmer than the design file. +6. **Painted late and outside the critical path.** + - The server renders the fixed layer: the gradient, an empty host `div` and the vignette, all `aria-hidden` and `pointer-events: none`. + - The client island renders only the host. When the browser is idle (`requestIdleCallback`, falling back to `setTimeout`), it imports `lib/film-scene/start.ts`, which creates the `` in the browser. The canvas is never rendered by React, because once its control is transferred to a worker it can never be transferred again. + - The canvas fades in over 1.6 s, as in the design, after its second frame. + - Nothing shifts layout or touches LCP, and every route keeps its prerendered shell. +7. **Off the main thread.** Where `OffscreenCanvas` supports WebGL2 in a worker, the canvas is transferred and drawn in `lib/film-scene/worker.ts`. Otherwise the same scene module runs on the main thread: it has no DOM access. Worker messages are `init`, `resize`, `pointer`, `visible` and `motion`, validated with Zod; replies are `ready`, `failed` and `lost`. A worker that replies `failed` causes one main thread attempt. A context lost after drawing gives up for good. +8. **Budgets.** + - The pixel ratio is capped at 1.5 (the design used 2). + - Below 768px, or with a coarse pointer: 2 strips at 240 ribbon segments and 35 bokeh. Otherwise: 3 strips at 400 segments and 70 bokeh. + - The speed is fixed at the design's 0.25. + - The loop pauses on `visibilitychange` and resumes without a time jump. + - Pointer moves are forwarded at most once per animation frame. The parallax lerp is made time based (`1 − 0.98^(60·dt)`), so it feels the same at 120 Hz. + - A `ResizeObserver` re-places the strips, whose screen position depends on the aspect ratio. Crossing the compact breakpoint rebuilds the scene at the other budget. + - Bokeh and strip layouts come from a seeded PRNG, so they are deterministic in tests and across remounts. +9. **Reduced motion.** Under `prefers-reduced-motion: reduce`, one still frame is drawn and nothing moves, including the parallax. The setting is watched live. +10. **Failure is silent.** No WebGL2, a failed compile or link, or a lost context leaves the CSS gradient and vignette, with nothing thrown and nothing logged. +11. **Kept from the app, not the design.** The font stays Inter, not Space Grotesk. The design's preview markup is not built. +12. **Scope.** Every route through the root layout. The OG image route is untouched, and the title page hero still paints over the background where it draws. + +## Expected files + +**Deleted:** `components/layout/ambient-background.tsx`, `ambient-background.test.tsx`, `ambient-canvas.tsx`, `ambient-canvas.test.tsx`, and all of `lib/backdrop/`. + +**New:** +- `components/layout/page-background.tsx` (Server Component): the fixed gradient layer, the canvas host and the vignette. +- `components/layout/page-background-canvas.tsx` (`"use client"`): idle import of `start.ts`, plus cleanup. Its `still` prop is used only by `/showcase`. +- `lib/film-scene/budget.ts`: pure. It chooses strips, segments, bokeh and pixel ratio from the viewport, the pointer and the DPR. +- `lib/film-scene/layout.ts`: pure. It holds the design's strip table, the helix and ribbon builders, frustum placement for an aspect ratio, and the seeded bokeh instances. +- `lib/film-scene/palette.ts`: reads and parses the tokens into sRGB 0 to 1, and computes the luminance cap from `--muted-foreground`. +- `lib/film-scene/shaders.ts`: the GLSL ES 3.00 sources for the strip and bokeh programs, with fog, lighting, the procedural film and the cap. +- `lib/film-scene/matrices.ts`: the perspective, look-at and per-strip model matrices. +- `lib/film-scene/scene.ts`: the WebGL2 scene, with `draw(frame)`, `resize`, `place(aspect)` and `dispose`. `dispose` deletes every buffer, VAO and program. +- `lib/film-scene/clock.ts`: the frame loop, covering reduced motion, the hidden tab pause and the time based lerp. +- `lib/film-scene/run.ts` (added while building): scene plus clock on one canvas, with context loss reporting. It is the piece the worker and the main thread share. +- `lib/film-scene/messages.ts` and `worker.ts`: the Zod protocol and the worker entry. +- `lib/film-scene/start.ts`: creates the canvas, reads the tokens, chooses the budget, starts the worker or the main thread path, and wires visibility, pointer, resize and motion. + +**Changed:** +- `app/globals.css`: remove the old tokens and utility; add the new tokens plus `@utility backdrop-gradient` and the new `@utility backdrop-vignette`. +- `app/layout.tsx` and its JSDoc: `` first in ``. +- `app/layout.test.ts` and `app/layout-purity.test.ts`: the new names. +- `app/showcase/page.tsx`: a "Page background" section with the token swatches and the still frame. +- `components/AGENTS.md`: the client boundary list, the end of the cyan exception, and the background rules under the new names. +- `prompts/ambient-background.md`: a "Superseded by prompts/film-strip-background.md" line. + +**Tests (new):** +- `budget.test.ts`: full, compact, coarse pointer and the DPR cap. +- `layout.test.ts`: the helix point count and bounds, the ribbon vertex, uv and index counts, the frustum placement for 16:9 and 9:16, and seeded bokeh determinism. +- `palette.test.ts`: token parsing, plus the cap keeping 4.5:1 for `#a8adbc`. +- `messages.test.ts`: valid and invalid messages. +- `clock.test.ts`: reduced motion draws once, a hidden tab requests no frames and resumes without a jump, and the lerp does not depend on frame rate. +- `scene.test.ts`: no WebGL2 or a failed link draws nothing, one draw call per strip plus one for the bokeh, depth writes back on before each clear, and dispose deletes its resources. +- `run.test.ts`: a lost context is reported, but the scene's own dispose is not. +- `page-background-canvas.test.tsx`: the worker and main thread paths, both kinds of failure and of loss, the breakpoint rebuild, pointer moves ignored under reduced motion, `still`, and unmount. +- `page-background.test.tsx`: `aria-hidden`, `pointer-events-none`, and the gradient and vignette present before anything loads. + +## Requirements and acceptance criteria + +- **AC-1:** Every page rendered by the root layout shows the background behind its content. Nothing in it is focusable, announced, or able to catch a pointer event. +- **AC-2:** No trace of the old background remains: `rg -i "lib/backdrop|ambient-(canvas|background)|backdrop-wave|backdrop-dust"` finds nothing outside `prompts/`. +- **AC-3:** Every colour comes from `globals.css` tokens, and `design-tokens-boundary.test.ts` passes. +- **AC-4:** No new runtime dependency. The scene chunk and the worker are absent from every route's initial JavaScript, and load only after hydration. +- **AC-5:** `/shows`, `/movies` and `/movies/[id]` keep the same prerender markers in the `pnpm build` route table as on `main`. +- **AC-6:** With `prefers-reduced-motion: reduce`, exactly one frame is drawn and the pointer moves nothing. +- **AC-7:** A hidden tab requests no animation frames, and returning resumes without a jump. +- **AC-8:** Without WebGL2, or after a context loss, the page shows the CSS gradient and vignette with no thrown error. +- **AC-9:** The pixel ratio is at most 1.5, and the compact budget applies below 768px or with a coarse pointer. +- **AC-10:** Where `OffscreenCanvas` supports it, rendering happens in a worker; otherwise on the main thread with the same output. +- **AC-11:** `text-muted-foreground` keeps at least 4.5:1 over the brightest background pixel. This is measured in the running app on `/privacy` at 1440 × 900 with the content hidden. +- **AC-12:** Layout and scrolling are unchanged. The navbar still scrolls away, the footer still pins to the bottom, and nothing shifts when the canvas fades in. +- **AC-13:** At a glance, the scene matches the design: three coiled film strips (upper right, lower left, and a small distant one at the bottom), rising bokeh, the teal gradient brighter at the upper left, and the vignette. + +## Security considerations + +- No user data, no network request (no unpkg or CDN script, no web font), and no new environment value. +- Worker messages are Zod validated and come only from our own island. +- No request state is read, so nothing private can enter a shared cache. + +## Automated checks + +- `pnpm typecheck`, `pnpm lint`, `pnpm test` and `pnpm build`. +- Compare the route table markers with `main` (AC-5). +- Confirm in the build output that the scene and worker are separate chunks (AC-4). +- Run the AC-2 `rg` sweep. + +## Manual test steps + +1. `git switch -c feat/film-strip-background`, then `pnpm dev`, and open `/shows`. The page paints in the teal gradient with the vignette, and within about two seconds the film strips and bokeh fade in. Moving the mouse tilts the view slightly. +2. Open a movie, `/watchlist`, `/sign-in`, `/privacy` and a bad URL (the 404). The background is behind each one. +3. DevTools → Rendering → emulate `prefers-reduced-motion: reduce`. The scene freezes. +4. Switch to another tab for 10 seconds and come back. The Performance panel shows no frames while hidden, and the animation resumes smoothly. +5. Device toolbar at iPhone width: two strips, fewer bokeh, smooth scrolling, and the navbar and footer behave as before. +6. DevTools → Sources → Threads: a worker thread runs the scene. +7. Disable WebGL (Chrome `--disable-webgl`, or Safari's Develop menu) and reload. Only the gradient and vignette show, with no console errors. +8. Tab through the navbar. Focus never lands on the background. +9. Open `/showcase` and check the Page background section: the swatches and the still frame. + +## As built (2026-10-11) + +- The design's table had a fourth strip, used only when its `strips` prop was 4. The app has no such prop, so only the three default strips are kept. +- Found in the running app: `gl.clear` respects `depthMask`, and the bokeh pass leaves it off. So every frame after the first kept the previous frame's depth. A redrawn still frame then lost its strips, and moving strips flickered. The mask is now turned back on before the clear, and `scene.test.ts` guards it. +- Measured on `/privacy` at 1440 × 900 with the content hidden (three frames over 13 s): the brightest pixel's luminance is 0.0206, so muted text keeps 6.64:1 (AC-11). +- Chunks: the worker is about 7.5 KB gzipped, including `zod/mini`, and the main thread scene about 5.7 KB. Neither is referenced by any prerendered page. From 7b1d13b422429354c9b143e0194e98fc1a7006be Mon Sep 17 00:00:00 2001 From: kris1027 Date: Sun, 11 Oct 2026 14:27:11 +0200 Subject: [PATCH 02/25] feat(background): replace the ambient background with the film strips PageBackground renders the teal gradient and vignette as the first paint and starts the film strip scene once the browser is idle. The point wave background, its tokens and its tests are removed. Co-Authored-By: Claude Opus 5.5 --- app/globals.css | 59 +++- app/layout-purity.test.ts | 6 +- app/layout.test.ts | 8 +- app/layout.tsx | 8 +- app/showcase/page.tsx | 48 ++- components/layout/ambient-background.tsx | 28 -- ...st.tsx => page-background-canvas.test.tsx} | 68 ++-- ...-canvas.tsx => page-background-canvas.tsx} | 20 +- ...ound.test.tsx => page-background.test.tsx} | 31 +- components/layout/page-background.tsx | 28 ++ lib/backdrop/backdrop.test.ts | 83 ----- lib/backdrop/backdrop.ts | 91 ------ lib/backdrop/camera.test.ts | 27 -- lib/backdrop/camera.ts | 60 ---- lib/backdrop/colors.test.ts | 71 ----- lib/backdrop/colors.ts | 97 ------ lib/backdrop/loop.test.ts | 133 -------- lib/backdrop/loop.ts | 131 -------- lib/backdrop/mount.ts | 254 --------------- lib/backdrop/params.test.ts | 130 -------- lib/backdrop/params.ts | 170 ---------- lib/backdrop/protocol.test.ts | 56 ---- lib/backdrop/protocol.ts | 92 ------ lib/backdrop/renderer.ts | 301 ------------------ lib/backdrop/shaders.ts | 164 ---------- lib/backdrop/worker.ts | 56 ---- 26 files changed, 173 insertions(+), 2047 deletions(-) delete mode 100644 components/layout/ambient-background.tsx rename components/layout/{ambient-canvas.test.tsx => page-background-canvas.test.tsx} (78%) rename components/layout/{ambient-canvas.tsx => page-background-canvas.tsx} (74%) rename components/layout/{ambient-background.test.tsx => page-background.test.tsx} (58%) create mode 100644 components/layout/page-background.tsx delete mode 100644 lib/backdrop/backdrop.test.ts delete mode 100644 lib/backdrop/backdrop.ts delete mode 100644 lib/backdrop/camera.test.ts delete mode 100644 lib/backdrop/camera.ts delete mode 100644 lib/backdrop/colors.test.ts delete mode 100644 lib/backdrop/colors.ts delete mode 100644 lib/backdrop/loop.test.ts delete mode 100644 lib/backdrop/loop.ts delete mode 100644 lib/backdrop/mount.ts delete mode 100644 lib/backdrop/params.test.ts delete mode 100644 lib/backdrop/params.ts delete mode 100644 lib/backdrop/protocol.test.ts delete mode 100644 lib/backdrop/protocol.ts delete mode 100644 lib/backdrop/renderer.ts delete mode 100644 lib/backdrop/shaders.ts delete mode 100644 lib/backdrop/worker.ts diff --git a/app/globals.css b/app/globals.css index 985b973..2e7af34 100644 --- a/app/globals.css +++ b/app/globals.css @@ -78,13 +78,30 @@ --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. Backdrop + * only: no surface or text may use them. 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-backdrop-glow: #04303a; + --color-backdrop-mid: #021820; + --color-backdrop-deep: #010a10; + --color-backdrop-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; @@ -355,17 +372,31 @@ } /* - * 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 at the upper 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-gradient { + background: radial-gradient( + ellipse at 25% 15%, + var(--color-backdrop-glow) 0%, + var(--color-backdrop-mid) 45%, + var(--color-backdrop-deep) 80%, + var(--color-backdrop-abyss) 100% + ); +} + +/* + * The vignette over the scene: lightly shaded in the middle, where content + * sits, and closing to near black at the edges, the design's own falloff. */ @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% + ellipse at 50% 45%, + color-mix(in srgb, var(--color-backdrop-abyss) 15%, transparent) 0%, + color-mix(in srgb, var(--color-backdrop-abyss) 55%, transparent) 60%, + color-mix(in srgb, var(--color-backdrop-abyss) 95%, transparent) 100% ); } 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..e7476af 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, @@ -400,30 +400,46 @@ export default function ShowcasePage() {
- - - + + + + + + + + + + + + + + + + + +
+
-
+
- backdrop-vignette over cyan + backdrop-gradient and backdrop-vignette, the first paint
-
-
-
- -
+
+
+ +
+
+ + the reduced motion still frame +
- - the reduced motion still frame -
diff --git a/components/layout/ambient-background.tsx b/components/layout/ambient-background.tsx deleted file mode 100644 index 1e1823d..0000000 --- a/components/layout/ambient-background.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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 ( -